Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,37 @@ 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
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
Expand Down
44 changes: 44 additions & 0 deletions integration/README.md
Original file line number Diff line number Diff line change
@@ -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.
140 changes: 140 additions & 0 deletions integration/container_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
71 changes: 71 additions & 0 deletions integration/go.mod
Original file line number Diff line number Diff line change
@@ -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 => ../
Loading
Loading