diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ed1fec..5c6cfa1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,19 @@ jobs: - name: Run tests with race detector run: go test -race ./... + integration: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + + - name: Run integration tests + run: cd integration && go test -v -timeout 10m ./... + build-docker-image: name: Build Docker image runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f47c407..6ec20c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ just run-serve # serve the example posts at localhost:8080 Run these before pushing: ```bash -just test # run the test suite +just test # run the unit test suite just test-race # run with the race detector just test-coverage # generate a coverage report just fmt # auto-format all Go code @@ -63,6 +63,29 @@ just lint # go vet + format check (mirrors CI) just test-all # full local CI simulation ``` +### Integration tests + +The `integration/` directory is a separate Go module containing black-box tests +that boot the CLI and Docker image against a real environment using +[testcontainers-go](https://golang.testcontainers.org/). **Docker is required.** + +```bash +just test-integration # run integration tests with Docker +``` + +Or run them directly: + +```bash +cd integration && go test -v -timeout 10m ./... +``` + +The unit suite (`just test`) deliberately excludes the integration module — +`go test ./...` stops at the nested `go.mod` boundary — so unit feedback +stays fast in CI even when integration tests are slow. + +In CI the integration tests run in a dedicated `integration` job so the two +stages report separately. + ## License Headers Every source file must carry an MPL 2.0 header. CI enforces this via the diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..d889ccb --- /dev/null +++ b/integration/README.md @@ -0,0 +1,44 @@ +# GoBlog Integration Tests + +Black-box integration tests for GoBlog using +[testcontainers-go](https://golang.testcontainers.org/). This is a separate Go +module so testcontainers' Docker dependency tree does not enter the published +library's `go.mod`. + +## Prerequisites + +- Go 1.26.3+ +- Docker (running) + +## Running + +From the repository root: + +```bash +just test-integration +``` + +Or directly: + +```bash +cd integration && go test -v -timeout 10m ./... +``` + +Tests that require Docker are skipped automatically when no Docker daemon is +reachable, so the in-process lifecycle tests (`TestRun_*`) still run in +Docker-less environments. + +## What's tested + +| Test | Kind | What it covers | +|---|---|---| +| `TestRun_BindError` | in-process | `Server.Run` returns a bind error when the port is already occupied | +| `TestRun_GracefulShutdown` | in-process | Context cancellation causes clean shutdown within the 10 s window | +| `TestServe_Smoke` | container | Docker image starts and serves HTTP 200 (Docker distribution channel) | +| `TestServe_LiveReload` | container | Watcher detects a file change and the running server reflects it | +| `TestServe_BlogRootFlag` | container | `-p` flag correctly prefixes all links with the configured blog root | + +## CI + +The integration tests run in a dedicated `integration` job in +`.github/workflows/test.yml`, separate from the fast unit `test` job. diff --git a/integration/container_test.go b/integration/container_test.go new file mode 100644 index 0000000..cf3af6a --- /dev/null +++ b/integration/container_test.go @@ -0,0 +1,140 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package integration_test + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// startContainer starts a goblog container using the pre-built test image. +// +// When postsDir is non-nil the directory is bind-mounted into the container +// at /posts. cmd overrides the image's default CMD — the ENTRYPOINT +// (./goblog serve) is preserved, so cmd is the argument list that follows it. +// Passing nil cmd uses the Dockerfile default (CMD ["/posts"]). +// +// Returns the running container and the "host:port" address of the mapped +// port 8080. +func startContainer(t *testing.T, ctx context.Context, postsDir *string, cmd []string) (testcontainers.Container, string) { + t.Helper() + + req := testcontainers.ContainerRequest{ + Image: imageTag, + Cmd: cmd, + ExposedPorts: []string{"8080/tcp"}, + WaitingFor: wait.ForListeningPort("8080/tcp").WithStartupTimeout(60 * time.Second), + } + + if postsDir != nil { + req.Mounts = testcontainers.ContainerMounts{ + { + Source: testcontainers.GenericBindMountSource{HostPath: *postsDir}, + Target: testcontainers.ContainerMountTarget("/posts"), + }, + } + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("startContainer: %v", err) + } + + host, err := c.Host(ctx) + if err != nil { + _ = c.Terminate(ctx) + t.Fatalf("container host: %v", err) + } + port, err := c.MappedPort(ctx, "8080") + if err != nil { + _ = c.Terminate(ctx) + t.Fatalf("container port: %v", err) + } + + return c, fmt.Sprintf("%s:%s", host, port.Port()) +} + +// TestServe_Smoke boots the Docker image against the built-in empty /posts +// directory and asserts that the index page is served with HTTP 200. +// This exercises the Docker distribution channel end-to-end. +func TestServe_Smoke(t *testing.T) { + skipIfNoDocker(t) + ctx := context.Background() + + c, addr := startContainer(t, ctx, nil, nil) + defer func() { _ = c.Terminate(ctx) }() + + eventually(t, 5*time.Second, 200*time.Millisecond, func() bool { + status, _ := httpGet(t, fmt.Sprintf("http://%s/", addr)) + return status == http.StatusOK + }) +} + +// TestServe_LiveReload verifies end-to-end live reload: after writing a new +// markdown file to the bind-mounted posts directory the served index page +// reflects the change, confirming that the watcher → server pipeline works +// through the full stack. +func TestServe_LiveReload(t *testing.T) { + skipIfNoDocker(t) + ctx := context.Background() + + dir := t.TempDir() + writePost(t, dir, "initial.md", minimalPost("Initial Post")) + + // -w enables the file watcher; /posts is bind-mounted from dir. + c, addr := startContainer(t, ctx, &dir, []string{"-w", "/posts"}) + defer func() { _ = c.Terminate(ctx) }() + + // Wait for the initial post to appear in the served index. + eventually(t, 10*time.Second, 500*time.Millisecond, func() bool { + _, body := httpGet(t, fmt.Sprintf("http://%s/", addr)) + return strings.Contains(body, "Initial Post") + }) + + // Write a new post on the host and assert the live server reflects it. + writePost(t, dir, "new-post.md", minimalPost("New Post After Reload")) + + eventually(t, 15*time.Second, 500*time.Millisecond, func() bool { + _, body := httpGet(t, fmt.Sprintf("http://%s/", addr)) + return strings.Contains(body, "New Post After Reload") + }) +} + +// TestServe_BlogRootFlag boots the server with -p /blog/ and verifies that +// the served HTML contains /blog/-prefixed links, confirming that the CLI flag +// is correctly wired through to the running process. +func TestServe_BlogRootFlag(t *testing.T) { + skipIfNoDocker(t) + ctx := context.Background() + + dir := t.TempDir() + writePost(t, dir, "post.md", minimalPost("Blog Root Post")) + + // -p /blog/ sets the blog root; /posts is the positional posts-dir argument. + c, addr := startContainer(t, ctx, &dir, []string{"-p", "/blog/", "/posts"}) + defer func() { _ = c.Terminate(ctx) }() + + // With -p /blog/ the index is served at /blog/, not /. + var body string + eventually(t, 10*time.Second, 500*time.Millisecond, func() bool { + status, b := httpGet(t, fmt.Sprintf("http://%s/blog/", addr)) + body = b + return status == http.StatusOK + }) + + if !strings.Contains(body, "/blog/") { + t.Errorf("expected response body to contain /blog/ links\nbody:\n%s", body) + } +} diff --git a/integration/go.mod b/integration/go.mod new file mode 100644 index 0000000..48b4f9d --- /dev/null +++ b/integration/go.mod @@ -0,0 +1,71 @@ +module github.com/harrydayexe/GoBlog/v2/integration + +go 1.26.3 + +require ( + github.com/harrydayexe/GoBlog/v2 v2.0.0 + github.com/testcontainers/testcontainers-go v0.42.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alecthomas/chroma/v2 v2.22.0 // indirect + github.com/caarlos0/env/v11 v11.3.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/harrydayexe/GoWebUtilities v1.4.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.abhg.dev/goldmark/frontmatter v0.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/harrydayexe/GoBlog/v2 => ../ diff --git a/integration/go.sum b/integration/go.sum new file mode 100644 index 0000000..ae26349 --- /dev/null +++ b/integration/go.sum @@ -0,0 +1,166 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.22.0 h1:PqEhf+ezz5F5owoDeOUKFzW+W3ZJDShNCaHg4sZuItI= +github.com/alecthomas/chroma/v2 v2.22.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/harrydayexe/GoWebUtilities v1.4.0 h1:0eu46pc0NbJFTyZJTrxDR+CmBUD6yiGbkVbN8sn8+1U= +github.com/harrydayexe/GoWebUtilities v1.4.0/go.mod h1:msGwhqkUbSAhhIR+uFnLE08OhBCwuJW0BQ5kUnBvI6I= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.abhg.dev/goldmark/frontmatter v0.3.0 h1:ZOrMkeyyYzhlbenFNmOXyGFx1dFE8TgBWAgZfs9D5RA= +go.abhg.dev/goldmark/frontmatter v0.3.0/go.mod h1:W3KXvVveKKxU1FIFZ7fgFFQrlkcolnDcOVmu19cCO9U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/integration/helpers_test.go b/integration/helpers_test.go new file mode 100644 index 0000000..38281e1 --- /dev/null +++ b/integration/helpers_test.go @@ -0,0 +1,76 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package integration_test + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" +) + +// minimalPost returns a valid markdown blog post body with the given title. +func minimalPost(title string) string { + return fmt.Sprintf(`--- +title: %q +date: 2026-01-01T00:00:00Z +description: "A test post for integration testing" +--- + +# %s + +This is a test post for integration testing. +`, title, title) +} + +// writePost writes a markdown file with the given name and body to dir. +func writePost(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil { + t.Fatalf("writePost %s: %v", name, err) + } +} + +// eventually polls fn every interval until it returns true or timeout elapses. +// The test is failed if fn does not return true within the timeout. +// +// The loop always calls fn() once more after the deadline to avoid missing +// conditions that become true inside the last sleep window. +func eventually(t *testing.T, timeout, interval time.Duration, fn func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if fn() { + return + } + if !time.Now().Before(deadline) { + break + } + time.Sleep(interval) + } + t.Fatalf("eventually: condition not met within %s", timeout) +} + +// httpGet performs a GET request and returns the status code and response body. +// Network errors are returned as (0, "") so callers can handle them uniformly +// inside eventually loops. +func httpGet(t *testing.T, url string) (int, string) { + t.Helper() + //nolint:gosec // test helper — URL is always test-controlled + resp, err := http.Get(url) + if err != nil { + return 0, "" + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Logf("httpGet %s: reading body: %v", url, err) + return resp.StatusCode, "" + } + return resp.StatusCode, string(body) +} diff --git a/integration/lifecycle_test.go b/integration/lifecycle_test.go new file mode 100644 index 0000000..89978fe --- /dev/null +++ b/integration/lifecycle_test.go @@ -0,0 +1,118 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package integration_test + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/harrydayexe/GoBlog/v2/pkg/config" + "github.com/harrydayexe/GoBlog/v2/pkg/server" +) + +// TestRun_BindError verifies that Server.Run surfaces a bind error when the +// configured port is already occupied, rather than silently swallowing the +// listenErr (pkg/server/server.go, the ListenAndServe goroutine). +func TestRun_BindError(t *testing.T) { + // Occupy a port to force the bind conflict. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + + dir := t.TempDir() + writePost(t, dir, "post.md", minimalPost("Hello World")) + + cfg := config.ServerConfig{ + Server: []config.BaseServerOption{ + config.WithPort(port), + config.WithHost("127.0.0.1"), + }, + } + srv, err := server.New(nil, os.DirFS(dir), cfg) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Run(ctx); err == nil { + t.Fatal("expected a bind error from Run, got nil") + } +} + +// TestRun_GracefulShutdown verifies that cancelling the context causes Run to +// return nil and complete well within the 10 s configured shutdown window. +func TestRun_GracefulShutdown(t *testing.T) { + dir := t.TempDir() + writePost(t, dir, "post.md", minimalPost("Hello World")) + + // Grab a free port by binding, recording it, then releasing it. + // NOTE: small TOCTOU window — the port is released here and re-bound by + // the server below. On a heavily loaded host another process could claim + // it in between, causing a rare spurious bind failure. Accepted: the + // server API binds by port number (ListenAndServe) and does not accept a + // pre-opened listener, so handing the fd over directly is not currently + // possible. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + cfg := config.ServerConfig{ + Server: []config.BaseServerOption{ + config.WithPort(port), + config.WithHost("127.0.0.1"), + }, + } + srv, err := server.New(nil, os.DirFS(dir), cfg) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // guarantees the Run goroutine is unwound if eventually fails + + done := make(chan error, 1) + go func() { done <- srv.Run(ctx) }() + + // Wait until the server is ready to serve requests. + addr := fmt.Sprintf("http://127.0.0.1:%d/", port) + eventually(t, 5*time.Second, 50*time.Millisecond, func() bool { + //nolint:gosec // test-controlled URL + resp, err := http.Get(addr) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK + }) + + // Cancel the context and measure how long shutdown takes. + start := time.Now() + cancel() + + select { + case err := <-done: + if err != nil { + t.Errorf("Run returned unexpected error: %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("shutdown took %s; want < 5 s", elapsed) + } + case <-time.After(15 * time.Second): + t.Fatal("server did not shut down within 15 s") + } +} diff --git a/integration/main_test.go b/integration/main_test.go new file mode 100644 index 0000000..523d8c6 --- /dev/null +++ b/integration/main_test.go @@ -0,0 +1,116 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package integration_test contains black-box integration tests for GoBlog. +// +// Tests in this package require Docker to be running for the container-based +// tests. The in-process lifecycle tests (TestRun_*) run without Docker. +// +// Run with: +// +// cd integration && go test -v ./... +package integration_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" +) + +// imageTag is the Docker image reference built once in TestMain and reused by +// all container tests. Empty string means Docker was unavailable. +var imageTag string + +// dockerSkip is set to true when Docker is not reachable on this host. +var dockerSkip bool + +// TestMain builds the goblog Docker image once before any tests run so the +// heavy Dockerfile build is paid only once per test binary invocation. +// Container tests are skipped gracefully when Docker is unavailable. +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +// run is the real body of TestMain. It is extracted so that deferred +// cleanup (notably cancel()) fires before os.Exit is called — os.Exit +// bypasses deferred functions in the calling frame. +func run(m *testing.M) int { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // First check whether Docker is reachable at all. A Health ping avoids + // the ambiguity of treating a Dockerfile build failure as "Docker down". + if !dockerHealthy(ctx) { + fmt.Fprintln(os.Stderr, "integration: Docker unavailable, container tests will be skipped") + dockerSkip = true + return m.Run() + } + + tag, err := buildTestImage(ctx) + if err != nil { + // Docker is up, so this is a genuine image-build failure (e.g. compile + // error, missing COPY target). Fail loudly rather than silently skipping. + fmt.Fprintf(os.Stderr, "integration: image build failed: %v\n", err) + return 1 + } + imageTag = tag + return m.Run() +} + +// dockerHealthy reports whether a Docker daemon is reachable by performing a +// lightweight Health ping, without building or starting any container. +func dockerHealthy(ctx context.Context) bool { + provider, err := testcontainers.NewDockerProvider() + if err != nil { + return false + } + defer provider.Close() + return provider.Health(ctx) == nil +} + +// buildTestImage builds the goblog Docker image from the repository Dockerfile. +// KeepImage: true ensures the built image persists between test runs so Docker's +// layer cache is used on subsequent invocations. +func buildTestImage(ctx context.Context) (string, error) { + const ( + repo = "goblog-integration-test" + tag = "latest" + ) + + // Creating the container with Started: false builds the image without + // starting a container, warming the Docker cache for all subsequent tests. + // Repo and Tag are separate fields in testcontainers-go v0.37+; combining + // them into Tag alone produces an invalid reference. + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: "..", + Dockerfile: "Dockerfile", + KeepImage: true, + Repo: repo, + Tag: tag, + }, + }, + Started: false, + }) + if err != nil { + return "", err + } + if c != nil { + _ = c.Terminate(ctx) + } + return repo + ":" + tag, nil +} + +// skipIfNoDocker skips the calling test when Docker is not available. +func skipIfNoDocker(t *testing.T) { + t.Helper() + if dockerSkip { + t.Skip("Docker not available on this host") + } +} diff --git a/justfile b/justfile index a8727fb..518e516 100644 --- a/justfile +++ b/justfile @@ -51,6 +51,11 @@ test-verbose: test-race: go test -race ./... +# Run integration tests (requires Docker) +[group("test")] +test-integration: + cd integration && go test -v -timeout 10m ./... + # Run tests with coverage profile [group("test")] test-coverage: