From 5e27512100e0e1bcabe834100e72d381c8901113 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 10:27:15 +0200 Subject: [PATCH 01/14] wave-run: paste a task's section and its table row, derive ownership from "Owns" W0 lists each task both as a ### section (the spec) and as a delegation table row (tier, ownership). The builder took only the row, so subagents lost the spec. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- .claude/skills/wave-run/task_prompt.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.claude/skills/wave-run/task_prompt.py b/.claude/skills/wave-run/task_prompt.py index 253c0049..0f26824f 100755 --- a/.claude/skills/wave-run/task_prompt.py +++ b/.claude/skills/wave-run/task_prompt.py @@ -5,8 +5,8 @@ task_prompt.py w1 U1 U2 U3 one prompt per task, separated by "=====" lines task_prompt.py w4 S1 -The task's table row (with the table header) or its "### " section is pasted in verbatim, so the -subagent never opens the wave file. The first line of each prompt, starting with "#", is for the coordinator: +The task's "### " section and its table row (with the table header) are pasted in verbatim, so the +subagent never opens the wave file. The section holds the spec; the row adds the tier and ownership. The first line of each prompt, starting with "#", is for the coordinator: the Agent tool `model` to use. Anything the script can't infer is left as ; fill it in before dispatching. """ @@ -119,9 +119,9 @@ def build(wave, task): path = ROOT / "docs" / "plan" / f"{wave.lower()}.md" text = path.read_text() lines = text.splitlines() - excerpt, fields = from_table(lines, task) - if excerpt is None: - excerpt = from_section(lines, task) or from_paragraph(text, task) + row, fields = from_table(lines, task) + section = from_section(lines, task) + excerpt = "\n\n".join(x for x in (section, row) if x) or from_paragraph(text, task) if excerpt is None: sys.exit(f"task {task} not found in {path.relative_to(ROOT)}") @@ -131,7 +131,9 @@ def build(wave, task): if not tier: m = re.search(r"\b(cheap|mid|strong)\b", excerpt) tier = m.group(1) if m else "" - where = next((v for k, v in fields.items() if k.startswith("package")), excerpt) + m = re.search(r"\bOwns (.+?)\.(?=\s|\||$)", excerpt, re.S) + owned = m.group(1).strip() if m else None + where = owned or next((v for k, v in fields.items() if k.startswith("package")), excerpt) pkgs = re.findall(r"`((?:pkg|cmd|e2e)/[\w/.-]+)`", where) dirs = [p.rsplit("/", 1)[0] if p.endswith(".go") else p.rstrip("/") for p in pkgs] pkg = " ".join(f"./{d}/..." for d in dict.fromkeys(dirs)) or "" @@ -149,11 +151,14 @@ def build(wave, task): browser = wave.lower() == "w6" if browser: pkg = "./e2e/browser/... TAGS=browser" - body = TEMPLATE.format(task=f"{wave.upper()}.{task}", excerpt=excerpt, pkg=pkg, done=done, report=REPORT, + template = TEMPLATE + if wave.lower() == "w0": + template = template.replace("You are adding tests to pcom.", "You are building pcom's test infrastructure.") + body = template.format(task=f"{wave.upper()}.{task}", excerpt=excerpt, pkg=pkg, done=done, report=REPORT, testcmd=(BROWSER_TESTCMD if browser else TESTCMD).format(pkg=pkg), - owns="_test.go" if browser else pkg if pkgs else "the task's packages") - + ">") + + ">")) return "\n".join([header, body]) From 88932578dfae6deaf8887a98d590cd33ac79deed Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 10:28:47 +0200 Subject: [PATCH 02/14] W0.T0.1: test databases from gogo's testcontainers/postgres Delete pcom's dockertest-based copy and add pkg/testutil/testdb.New(t), which resolves pcom's migrations directory and calls gogo's postgres.New. Migrations are applied once into a template database and each test gets a copy, dropped on cleanup. Existing DB tests move to testdb.New. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- README.md | 2 +- go.mod | 48 ++++-- go.sum | 153 +++++++++++------- pkg/feedops/feeder/feeder_test.go | 22 +-- pkg/feedops/feedops_test.go | 10 +- pkg/testutil/testdb/testdb.go | 25 +++ pkg/web/api_test.go | 6 +- testcontainers/postgres/README.md | 113 ------------- testcontainers/postgres/postgres.go | 193 ----------------------- testcontainers/postgres/postgres_test.go | 78 --------- 10 files changed, 167 insertions(+), 483 deletions(-) create mode 100644 pkg/testutil/testdb/testdb.go delete mode 100644 testcontainers/postgres/README.md delete mode 100644 testcontainers/postgres/postgres.go delete mode 100644 testcontainers/postgres/postgres_test.go diff --git a/README.md b/README.md index 6afb6508..2a085742 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ make lint # golangci-lint ``` Docker must be running: tests that touch the database start a Postgres -container via `testcontainers/postgres`. +container via `pkg/testutil/testdb`. ### psql access diff --git a/go.mod b/go.mod index 69b553e6..25d93848 100644 --- a/go.mod +++ b/go.mod @@ -28,10 +28,8 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/mmcdole/gofeed v1.4.2 - github.com/ory/dockertest/v3 v3.12.0 github.com/ovechkin-dm/mockio/v2 v2.0.5 github.com/pkg/errors v0.9.1 - github.com/rubenv/sql-migrate v1.8.1 github.com/samber/lo v1.53.0 github.com/samber/mo v1.17.0 github.com/stretchr/testify v1.12.1 @@ -49,7 +47,6 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/alecthomas/repr v0.4.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect @@ -70,60 +67,84 @@ require ( github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect - github.com/containerd/continuity v0.4.5 // 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/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect - github.com/docker/cli v27.4.1+incompatible // indirect - github.com/docker/docker v28.0.0+incompatible // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/ericlagergren/decimal v0.0.0-20190420051523-6335edbaa640 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // 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.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mailjet/mailjet-apiv3-go/v3 v3.2.0 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/mmcdole/goxpp/v2 v2.0.0 // 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.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runc v1.3.6 // indirect github.com/ovechkin-dm/go-dyno v0.5.3 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.5.0 // indirect + github.com/testcontainers/testcontainers-go v0.44.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/volatiletech/inflect v0.0.1 // indirect github.com/volatiletech/randomize v0.0.1 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.29.0 // indirect golang.org/x/crypto v0.56.0 // indirect @@ -133,5 +154,4 @@ require ( golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 749fa123..ba9d4801 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1MRDJM= @@ -14,8 +16,6 @@ github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jB github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -81,35 +81,47 @@ github.com/can3p/gogo v0.0.2 h1:AF9iTLX15L7CiQCxjW+Z+MTCrEMSTms5SBCpn8PFkno= github.com/can3p/gogo v0.0.2/go.mod h1:cl/j88RimX4lgmI+VzH+zVkAEtzzguHCGSzOOmqYSsk= 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/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= -github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +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.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidbyttow/govips/v2 v2.19.0 h1:vIFpRodf/jrQlnKetj0BaQ1OwGNR+bR+QIBQ2sgxtxc= github.com/davidbyttow/govips/v2 v2.19.0/go.mod h1:QK5liLrx7YaHC7xPcYnm4KaQIYvu9xSGrBt9goXrfgo= +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 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI= -github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v28.0.0+incompatible h1:Olh0KS820sJ7nPsBKChVhk5pzqcwDR15fumfAd/p9hM= -github.com/docker/docker v28.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= 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/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/ericlagergren/decimal v0.0.0-20190420051523-6335edbaa640 h1:VMAacqPM03GapxpfNORtKNl9o6Uws1BQYL54WjmolN0= github.com/ericlagergren/decimal v0.0.0-20190420051523-6335edbaa640/go.mod h1:mdYyfAkzn9kyJ/kMk/7WE9ufl9lflh+2NvecQ5mAghs= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= @@ -132,6 +144,14 @@ github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6 github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +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/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -142,8 +162,6 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -151,16 +169,12 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= @@ -178,6 +192,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf 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/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -187,8 +209,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -206,6 +228,10 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailjet/mailjet-apiv3-go/v3 v3.2.0 h1:/gjowTurgK4iqLzVAQmjtcldyaW6tbJNA4PzZsuj2Ks= github.com/mailjet/mailjet-apiv3-go/v3 v3.2.0/go.mod h1:Nw3mVzRxV0CVDTlzaRcADGKt4PMNbT7gYIyEtjMrVIM= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= @@ -213,6 +239,8 @@ github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyi github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= @@ -224,8 +252,20 @@ github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5r github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os= 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.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +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.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= 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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -238,10 +278,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 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/opencontainers/runc v1.3.6 h1:SLGIymCtsk80iNPWgbc8dtjI30r+5mTVV+4dN8/17Sk= -github.com/opencontainers/runc v1.3.6/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/HfX709lDv+rr4= -github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= -github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/ovechkin-dm/go-dyno v0.5.3 h1:/MrL26kFTxbLj/qPbEtR4piVeFYUqjSamAgWpuzeD/k= github.com/ovechkin-dm/go-dyno v0.5.3/go.mod h1:CcJNuo7AbePMoRNpM3i1jC1Rp9kHEMyWozNdWzR+0ys= github.com/ovechkin-dm/mockio/v2 v2.0.5 h1:YLBgYJ3y9gRkrOn7E3Jg7DNC4uiH3HhXpTP01Op3T4I= @@ -257,6 +293,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= @@ -280,6 +318,8 @@ github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvK github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -291,6 +331,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -303,6 +345,14 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/teekennedy/goldmark-markdown v0.5.1 h1:2lIlJ3AcIwaD1wFl4dflJSJFMhRTKEsEj+asVsu6M/0= github.com/teekennedy/goldmark-markdown v0.5.1/go.mod h1:so260mNSPELuRyynZY18719dRYlD+OSnAovqsyrOMOM= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= @@ -319,24 +369,31 @@ github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni github.com/volatiletech/strmangle v0.0.8 h1:UZkTDFIjZcL1Lk4BXhGsxcyXxNcWuM5ZwdzZc0sJcWg= github.com/volatiletech/strmangle v0.0.8/go.mod h1:ycDvbDkjDvhC0NUU8w3fWwl5JEMTV56vTKXzR3GeR+0= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg= github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU= +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/toc v0.11.0 h1:IRixVy3/yVPKvFBc37EeBPi8XLTXrtH6BYaonSjkF8o= go.abhg.dev/goldmark/toc v0.11.0/go.mod h1:XMFIoI1Sm6dwF9vKzVDOYE/g1o5BmKXghLG8q/wJNww= go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +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.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= @@ -344,63 +401,45 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -415,9 +454,9 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +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/pkg/feedops/feeder/feeder_test.go b/pkg/feedops/feeder/feeder_test.go index c2a1d7ed..2345e38b 100644 --- a/pkg/feedops/feeder/feeder_test.go +++ b/pkg/feedops/feeder/feeder_test.go @@ -11,7 +11,7 @@ import ( "github.com/can3p/pcom/pkg/feedops/feeder" "github.com/can3p/pcom/pkg/feedops/reader" "github.com/can3p/pcom/pkg/feedops/testutil" - "github.com/can3p/pcom/testcontainers/postgres" + "github.com/can3p/pcom/pkg/testutil/testdb" . "github.com/ovechkin-dm/mockio/v2/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,9 +20,7 @@ import ( ) func TestGetFeedsToRefresh(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctx := context.Background() @@ -64,9 +62,7 @@ func TestGetFeedsToRefresh(t *testing.T) { } func TestSaveFetchFailure(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctx := context.Background() @@ -88,9 +84,7 @@ func TestSaveFetchFailure(t *testing.T) { } func TestLockFeed(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctx := context.Background() @@ -129,9 +123,7 @@ func createFeedItems(num int, startTime time.Time) []*reader.Item { } func TestSaveFeed(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctrl := NewMockController(t) @@ -180,9 +172,7 @@ func TestSaveFeed(t *testing.T) { } func TestSaveFeedInitialAndFollowUp(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctrl := NewMockController(t) diff --git a/pkg/feedops/feedops_test.go b/pkg/feedops/feedops_test.go index 79ac5b39..0523f0c2 100644 --- a/pkg/feedops/feedops_test.go +++ b/pkg/feedops/feedops_test.go @@ -7,14 +7,12 @@ import ( "github.com/can3p/pcom/pkg/feedops" "github.com/can3p/pcom/pkg/feedops/testutil" - "github.com/can3p/pcom/testcontainers/postgres" + "github.com/can3p/pcom/pkg/testutil/testdb" "github.com/stretchr/testify/require" ) func TestGetRssFeedItems_FiltersDismissedItems(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctx := context.Background() @@ -52,9 +50,7 @@ func TestGetRssFeedItems_FiltersDismissedItems(t *testing.T) { } func TestGetRssFeedItems_EmptyResult(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctx := context.Background() diff --git a/pkg/testutil/testdb/testdb.go b/pkg/testutil/testdb/testdb.go new file mode 100644 index 00000000..de9ad722 --- /dev/null +++ b/pkg/testutil/testdb/testdb.go @@ -0,0 +1,25 @@ +// Package testdb gives a test its own empty Postgres database with pcom's +// migrations applied. The database is dropped when the test ends. +package testdb + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/can3p/gogo/testcontainers/postgres" +) + +// migrationsDir is pcom's migrations directory, resolved from this file so +// that it works from any package's working directory. +var migrationsDir = func() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "..", "migrations") +}() + +// New returns a fresh, fully migrated database. Use TestDB.DB (*sqlx.DB) as +// the executor and TestDB.URL to hand the database to a subprocess. +func New(t testing.TB) *postgres.TestDB { + t.Helper() + return postgres.New(t, postgres.WithMigrationsDir(migrationsDir)) +} diff --git a/pkg/web/api_test.go b/pkg/web/api_test.go index a2bc4647..3e4836f3 100644 --- a/pkg/web/api_test.go +++ b/pkg/web/api_test.go @@ -8,9 +8,9 @@ import ( "github.com/can3p/pcom/pkg/feedops/testutil" "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/testutil/testdb" "github.com/can3p/pcom/pkg/util/ginhelpers" "github.com/can3p/pcom/pkg/web" - "github.com/can3p/pcom/testcontainers/postgres" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -18,9 +18,7 @@ import ( ) func TestApiDeletePost_OnlyAuthorCanDelete(t *testing.T) { - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() + testDB := testdb.New(t) ctx := context.Background() diff --git a/testcontainers/postgres/README.md b/testcontainers/postgres/README.md deleted file mode 100644 index 461aa5b2..00000000 --- a/testcontainers/postgres/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# PostgreSQL Test Container Helper - -This package provides a simple test helper for spinning up PostgreSQL containers with migrations applied. - -## Features - -- **Singleton container pattern**: Starts one PostgreSQL container shared across all tests -- **Per-test database isolation**: Each test gets its own database with migrations applied -- **Parallel test support**: Tests can run in parallel without interfering with each other -- Uses `github.com/ory/dockertest` for container management -- Automatically applies all migrations from the `migrations/` folder -- Provides a `*sqlx.DB` handle for testing -- Simple one-liner setup with cleanup - -## Usage - -### Basic Usage - -```go -package mypackage_test - -import ( - "testing" - "github.com/can3p/pcom/testcontainers/postgres" - "github.com/stretchr/testify/require" -) - -func TestMain(m *testing.M) { - code := m.Run() - postgres.Cleanup() // Cleanup container after all tests - if code != 0 { - panic(code) - } -} - -func TestMyFunction(t *testing.T) { - // Get a test database with all migrations applied - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer testDB.Close() // Drops the database (not the container) - - // Use testDB.DB (*sqlx.DB) for your tests - var count int - err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") - require.NoError(t, err) - - // Your test logic here... -} -``` - -### Parallel Tests - -```go -func TestParallelOperations(t *testing.T) { - t.Run("test1", func(t *testing.T) { - t.Parallel() - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer testDB.Close() - - // Each test gets its own isolated database - // Tests can run in parallel without conflicts - }) - - t.Run("test2", func(t *testing.T) { - t.Parallel() - testDB, err := postgres.NewTestDB() - require.NoError(t, err) - defer testDB.Close() - - // Completely isolated from test1 - }) -} -``` - -## How It Works - -1. **First call to `NewTestDB()`**: Starts a shared PostgreSQL container (happens once) -2. **Each `NewTestDB()` call**: Creates a new database with a unique name and applies all migrations -3. **`TestDB.Close()`**: Drops the test database (container keeps running) -4. **`Cleanup()`**: Stops and removes the container (call in `TestMain`) - -## Requirements - -- Docker must be running on the host machine -- The helper will automatically: - - Pull the `postgres:16-alpine` image if not present - - Start a single shared container on first use - - Create isolated databases for each test - - Apply all migrations from `migrations/` folder - - Set container to auto-remove after 300 seconds - -## API - -### `NewTestDB() (*TestDB, error)` - -Creates a new isolated database with migrations applied. Uses a singleton container that is created on first call. - -Returns: -- `*TestDB`: Database handle with connection -- `error`: Any error during setup - -### `TestDB.DB` - -A `*sqlx.DB` handle connected to the test database. - -### `TestDB.Close() error` - -Drops the test database and closes connections. The container continues running for other tests. Should be called with `defer` after creating the test DB. - -### `Cleanup() error` - -Stops and removes the shared PostgreSQL container. Should be called in `TestMain` after all tests complete. diff --git a/testcontainers/postgres/postgres.go b/testcontainers/postgres/postgres.go deleted file mode 100644 index 74d6fae9..00000000 --- a/testcontainers/postgres/postgres.go +++ /dev/null @@ -1,193 +0,0 @@ -package postgres - -import ( - "database/sql" - "fmt" - "log" - "path/filepath" - "runtime" - "sync" - "sync/atomic" - - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - _ "github.com/lib/pq" - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" - migrate "github.com/rubenv/sql-migrate" -) - -var ( - containerOnce sync.Once - sharedContainer *containerInstance - containerInitErr error - dbCounter atomic.Uint64 -) - -type containerInstance struct { - pool *dockertest.Pool - resource *dockertest.Resource - hostAndPort string -} - -type TestDB struct { - DB *sqlx.DB - dbName string - adminDB *sql.DB -} - -func (t *TestDB) Close() error { - if t.DB != nil { - if err := t.DB.Close(); err != nil { - return err - } - } - - if t.adminDB != nil && t.dbName != "" { - _, err := t.adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", t.dbName)) - if err != nil { - return fmt.Errorf("failed to drop database %s: %w", t.dbName, err) - } - } - - if t.adminDB != nil { - if err := t.adminDB.Close(); err != nil { - return err - } - } - - return nil -} - -func Cleanup() error { - if sharedContainer != nil && sharedContainer.pool != nil && sharedContainer.resource != nil { - if err := sharedContainer.pool.Purge(sharedContainer.resource); err != nil { - return fmt.Errorf("failed to purge container: %w", err) - } - sharedContainer = nil - } - return nil -} - -func getOrCreateContainer() (*containerInstance, error) { - containerOnce.Do(func() { - pool, err := dockertest.NewPool("") - if err != nil { - containerInitErr = fmt.Errorf("could not construct pool: %w", err) - return - } - - err = pool.Client.Ping() - if err != nil { - containerInitErr = fmt.Errorf("could not connect to Docker: %w", err) - return - } - - resource, err := pool.RunWithOptions(&dockertest.RunOptions{ - Repository: "postgres", - Tag: "16-alpine", - Env: []string{ - "POSTGRES_PASSWORD=secret", - "POSTGRES_USER=testuser", - "POSTGRES_DB=postgres", - "listen_addresses = '*'", - }, - }, func(config *docker.HostConfig) { - config.AutoRemove = true - config.RestartPolicy = docker.RestartPolicy{Name: "no"} - }) - if err != nil { - containerInitErr = fmt.Errorf("could not start resource: %w", err) - return - } - - hostAndPort := resource.GetHostPort("5432/tcp") - _ = resource.Expire(300) - - var testConn *sql.DB - if err = pool.Retry(func() error { - testConn, err = sql.Open("postgres", fmt.Sprintf("postgres://testuser:secret@%s/postgres?sslmode=disable", hostAndPort)) - if err != nil { - return err - } - defer func() { _ = testConn.Close() }() - return testConn.Ping() - }); err != nil { - _ = pool.Purge(resource) - containerInitErr = fmt.Errorf("could not connect to database: %w", err) - return - } - - log.Printf("Started shared PostgreSQL container at %s", hostAndPort) - - sharedContainer = &containerInstance{ - pool: pool, - resource: resource, - hostAndPort: hostAndPort, - } - }) - - if containerInitErr != nil { - return nil, containerInitErr - } - - return sharedContainer, nil -} - -func NewTestDB() (*TestDB, error) { - container, err := getOrCreateContainer() - if err != nil { - return nil, err - } - - dbNum := dbCounter.Add(1) - dbName := fmt.Sprintf("testdb_%s_%d", uuid.New().String()[:8], dbNum) - - adminURL := fmt.Sprintf("postgres://testuser:secret@%s/postgres?sslmode=disable", container.hostAndPort) - adminDB, err := sql.Open("postgres", adminURL) - if err != nil { - return nil, fmt.Errorf("could not connect to admin database: %w", err) - } - - _, err = adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)) - if err != nil { - _ = adminDB.Close() - return nil, fmt.Errorf("could not create database %s: %w", dbName, err) - } - - dbURL := fmt.Sprintf("postgres://testuser:secret@%s/%s?sslmode=disable", container.hostAndPort, dbName) - db, err := sql.Open("postgres", dbURL) - if err != nil { - _ = adminDB.Close() - return nil, fmt.Errorf("could not connect to test database: %w", err) - } - - _, filename, _, ok := runtime.Caller(0) - if !ok { - _ = db.Close() - _ = adminDB.Close() - return nil, fmt.Errorf("could not get caller information") - } - migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") - - migrations := &migrate.FileMigrationSource{ - Dir: migrationsDir, - } - - n, err := migrate.Exec(db, "postgres", migrations, migrate.Up) - if err != nil { - _ = db.Close() - _ = adminDB.Close() - return nil, fmt.Errorf("could not run migrations: %w", err) - } - - log.Printf("Created database %s and applied %d migrations", dbName, n) - - sqlxDB := sqlx.NewDb(db, "postgres") - - return &TestDB{ - DB: sqlxDB, - dbName: dbName, - adminDB: adminDB, - }, nil -} diff --git a/testcontainers/postgres/postgres_test.go b/testcontainers/postgres/postgres_test.go deleted file mode 100644 index 0b2eb66f..00000000 --- a/testcontainers/postgres/postgres_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package postgres - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMain(m *testing.M) { - code := m.Run() - _ = Cleanup() - if code != 0 { - panic(code) - } -} - -func TestNewTestDB(t *testing.T) { - testDB, err := NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() - - var result int - err = testDB.DB.Get(&result, "SELECT 1") - require.NoError(t, err) - assert.Equal(t, 1, result) - - var count int - err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") - require.NoError(t, err) - assert.Equal(t, 0, count) -} - -func TestParallelDatabases(t *testing.T) { - t.Run("db1", func(t *testing.T) { - t.Parallel() - testDB, err := NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() - - _, err = testDB.DB.Exec("INSERT INTO users (id, email, timezone, username) VALUES ($1, $2, $3, $4)", - "00000000-0000-0000-0000-000000000001", "test1@example.com", "UTC", "user1") - require.NoError(t, err) - - var count int - err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") - require.NoError(t, err) - assert.Equal(t, 1, count) - }) - - t.Run("db2", func(t *testing.T) { - t.Parallel() - testDB, err := NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() - - var count int - err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") - require.NoError(t, err) - assert.Equal(t, 0, count) - }) - - t.Run("db3", func(t *testing.T) { - t.Parallel() - testDB, err := NewTestDB() - require.NoError(t, err) - defer func() { _ = testDB.Close() }() - - _, err = testDB.DB.Exec("INSERT INTO users (id, email, timezone, username) VALUES ($1, $2, $3, $4)", - "00000000-0000-0000-0000-000000000002", "test2@example.com", "UTC", "user2") - require.NoError(t, err) - - var count int - err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") - require.NoError(t, err) - assert.Equal(t, 1, count) - }) -} From 3784a758c9f2b666e7fd35e716de548b5b2875cb Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:07:59 +0200 Subject: [PATCH 03/14] W0.T0.3: test fakes and helpers fakesender records mail and can fail on demand, fakestorage is an in-memory MediaStorage with injectable errors, ginctx builds a gin context with a cookie session (optionally logged in, with CSP nonces), golden compares against testdata with UPDATE_GOLDEN=1, and testutil.Must unwraps (value, error) pairs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- pkg/testutil/fakesender/fakesender.go | 77 ++++++++++++ pkg/testutil/fakesender/fakesender_test.go | 56 +++++++++ pkg/testutil/fakestorage/fakestorage.go | 116 +++++++++++++++++++ pkg/testutil/fakestorage/fakestorage_test.go | 76 ++++++++++++ pkg/testutil/ginctx/ginctx.go | 79 +++++++++++++ pkg/testutil/ginctx/ginctx_test.go | 72 ++++++++++++ pkg/testutil/golden/golden.go | 55 +++++++++ pkg/testutil/golden/golden_test.go | 75 ++++++++++++ pkg/testutil/must.go | 28 +++++ pkg/testutil/must_test.go | 48 ++++++++ 10 files changed, 682 insertions(+) create mode 100644 pkg/testutil/fakesender/fakesender.go create mode 100644 pkg/testutil/fakesender/fakesender_test.go create mode 100644 pkg/testutil/fakestorage/fakestorage.go create mode 100644 pkg/testutil/fakestorage/fakestorage_test.go create mode 100644 pkg/testutil/ginctx/ginctx.go create mode 100644 pkg/testutil/ginctx/ginctx_test.go create mode 100644 pkg/testutil/golden/golden.go create mode 100644 pkg/testutil/golden/golden_test.go create mode 100644 pkg/testutil/must.go create mode 100644 pkg/testutil/must_test.go diff --git a/pkg/testutil/fakesender/fakesender.go b/pkg/testutil/fakesender/fakesender.go new file mode 100644 index 00000000..b3e24314 --- /dev/null +++ b/pkg/testutil/fakesender/fakesender.go @@ -0,0 +1,77 @@ +// Package fakesender is a fake of github.com/can3p/gogo/sender.Sender for +// unit and package tests: it records every mail instead of delivering it. +// From R2 on, real delivery in E2E and development paths is exercised +// through tommy instead; this fake stays the right tool for tests that only +// care what the code under test tried to send. +package fakesender + +import ( + "context" + "sync" + + "github.com/can3p/gogo/sender" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// compile-time check that Sender implements gogo/sender.Sender. +var _ sender.Sender = (*Sender)(nil) + +// Recorded is one call to Send. +type Recorded struct { + UniqueID string + EmailType string + Mail *sender.Mail +} + +// Sender records every mail passed to Send. The zero value is ready to use; +// New is equivalent and reads slightly better at a call site. It is safe +// for concurrent use. +type Sender struct { + mu sync.Mutex + sent []Recorded + err error +} + +// New returns a Sender that records mail and never fails. +func New() *Sender { + return &Sender{} +} + +// FailWith makes every subsequent Send return err instead of recording the +// mail. Pass nil to go back to recording. +func (s *Sender) FailWith(err error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.err = err +} + +// Send implements gogo/sender.Sender. exec is accepted, to match the +// interface, but is not used: this fake never touches the database. +func (s *Sender) Send(ctx context.Context, exec boil.ContextExecutor, uniqueID string, emailType string, mail *sender.Mail) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.err != nil { + return s.err + } + + s.sent = append(s.sent, Recorded{ + UniqueID: uniqueID, + EmailType: emailType, + Mail: mail, + }) + + return nil +} + +// Sent returns every mail recorded so far, in the order Send was called. +func (s *Sender) Sent() []Recorded { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]Recorded, len(s.sent)) + copy(out, s.sent) + + return out +} diff --git a/pkg/testutil/fakesender/fakesender_test.go b/pkg/testutil/fakesender/fakesender_test.go new file mode 100644 index 00000000..9a2498d5 --- /dev/null +++ b/pkg/testutil/fakesender/fakesender_test.go @@ -0,0 +1,56 @@ +package fakesender_test + +import ( + "context" + "errors" + "net/mail" + "testing" + + "github.com/can3p/gogo/sender" + "github.com/can3p/pcom/pkg/testutil/fakesender" + "github.com/stretchr/testify/require" +) + +func TestSender_RecordsMail(t *testing.T) { + t.Parallel() + + s := fakesender.New() + + m := &sender.Mail{ + From: mail.Address{Address: "from@example.com"}, + To: []mail.Address{{Address: "to@example.com"}}, + Subject: "hi", + } + + require.NoError(t, s.Send(context.Background(), nil, "unique-1", "welcome", m)) + + other := &sender.Mail{Subject: "second"} + require.NoError(t, s.Send(context.Background(), nil, "unique-2", "confirm_signup", other)) + + sent := s.Sent() + require.Len(t, sent, 2) + + require.Equal(t, "unique-1", sent[0].UniqueID) + require.Equal(t, "welcome", sent[0].EmailType) + require.Same(t, m, sent[0].Mail) + + require.Equal(t, "unique-2", sent[1].UniqueID) + require.Equal(t, "confirm_signup", sent[1].EmailType) + require.Same(t, other, sent[1].Mail) +} + +func TestSender_FailWith(t *testing.T) { + t.Parallel() + + s := fakesender.New() + boom := errors.New("boom") + s.FailWith(boom) + + err := s.Send(context.Background(), nil, "unique-3", "welcome", &sender.Mail{}) + require.ErrorIs(t, err, boom) + require.Empty(t, s.Sent(), "a failed send should not be recorded") + + s.FailWith(nil) + require.NoError(t, s.Send(context.Background(), nil, "unique-4", "welcome", &sender.Mail{})) + require.Len(t, s.Sent(), 1, "recording resumes once FailWith(nil) clears the error") +} diff --git a/pkg/testutil/fakestorage/fakestorage.go b/pkg/testutil/fakestorage/fakestorage.go new file mode 100644 index 00000000..3cc98744 --- /dev/null +++ b/pkg/testutil/fakestorage/fakestorage.go @@ -0,0 +1,116 @@ +// Package fakestorage is an in-memory implementation of +// github.com/can3p/pcom/pkg/media/server.MediaStorage, for tests that +// exercise upload/download code without a real object store. +package fakestorage + +import ( + "bytes" + "context" + "io" + "net/http" + "sync" + + "github.com/can3p/pcom/pkg/media" + "github.com/can3p/pcom/pkg/media/server" +) + +// compile-time check that Storage implements server.MediaStorage. +var _ server.MediaStorage = (*Storage)(nil) + +type object struct { + data []byte + contentType string +} + +// Storage is an in-memory server.MediaStorage. Use New to construct one; it +// is safe for concurrent use. +type Storage struct { + mu sync.Mutex + objects map[string]object + + uploadErr error + downloadErr error + existsErr error +} + +// New returns an empty Storage. +func New() *Storage { + return &Storage{objects: map[string]object{}} +} + +// FailUploadWith makes UploadFile return err. Pass nil to clear it. +func (s *Storage) FailUploadWith(err error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.uploadErr = err +} + +// FailDownloadWith makes DownloadFile return err. Pass nil to clear it. +func (s *Storage) FailDownloadWith(err error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.downloadErr = err +} + +// FailExistsWith makes ObjectExists return err. Pass nil to clear it. +func (s *Storage) FailExistsWith(err error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.existsErr = err +} + +// UploadFile implements server.MediaStorage. +func (s *Storage) UploadFile(ctx context.Context, fname string, b []byte, contentType string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.uploadErr != nil { + return s.uploadErr + } + + cp := make([]byte, len(b)) + copy(cp, b) + + if contentType == "" { + contentType = http.DetectContentType(cp) + } + + s.objects[fname] = object{data: cp, contentType: contentType} + + return nil +} + +// DownloadFile implements server.MediaStorage. A missing file returns +// media.ErrNotFound, the same sentinel the real storage backends use. +func (s *Storage) DownloadFile(ctx context.Context, fname string) (io.ReadCloser, int64, string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.downloadErr != nil { + return nil, 0, "", s.downloadErr + } + + obj, ok := s.objects[fname] + if !ok { + return nil, 0, "", media.ErrNotFound + } + + return io.NopCloser(bytes.NewReader(obj.data)), int64(len(obj.data)), obj.contentType, nil +} + +// ObjectExists implements server.MediaStorage. +func (s *Storage) ObjectExists(ctx context.Context, fname string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.existsErr != nil { + return false, s.existsErr + } + + _, ok := s.objects[fname] + + return ok, nil +} diff --git a/pkg/testutil/fakestorage/fakestorage_test.go b/pkg/testutil/fakestorage/fakestorage_test.go new file mode 100644 index 00000000..d8812991 --- /dev/null +++ b/pkg/testutil/fakestorage/fakestorage_test.go @@ -0,0 +1,76 @@ +package fakestorage_test + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/can3p/pcom/pkg/media" + "github.com/can3p/pcom/pkg/testutil/fakestorage" + "github.com/stretchr/testify/require" +) + +func TestStorage_RoundTrips(t *testing.T) { + t.Parallel() + + s := fakestorage.New() + ctx := context.Background() + + require.NoError(t, s.UploadFile(ctx, "a.png", []byte("hello"), "image/png")) + + exists, err := s.ObjectExists(ctx, "a.png") + require.NoError(t, err) + require.True(t, exists) + + r, size, contentType, err := s.DownloadFile(ctx, "a.png") + require.NoError(t, err) + defer r.Close() + + got, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, "hello", string(got)) + require.EqualValues(t, len("hello"), size) + require.Equal(t, "image/png", contentType) +} + +func TestStorage_MissingObject(t *testing.T) { + t.Parallel() + + s := fakestorage.New() + ctx := context.Background() + + exists, err := s.ObjectExists(ctx, "missing.png") + require.NoError(t, err) + require.False(t, exists) + + _, _, _, err = s.DownloadFile(ctx, "missing.png") + require.ErrorIs(t, err, media.ErrNotFound) +} + +func TestStorage_InjectedErrors(t *testing.T) { + t.Parallel() + + s := fakestorage.New() + ctx := context.Background() + boom := errors.New("boom") + + s.FailUploadWith(boom) + require.ErrorIs(t, s.UploadFile(ctx, "a.png", nil, ""), boom) + s.FailUploadWith(nil) + require.NoError(t, s.UploadFile(ctx, "a.png", []byte("x"), "text/plain")) + + s.FailDownloadWith(boom) + _, _, _, err := s.DownloadFile(ctx, "a.png") + require.ErrorIs(t, err, boom) + s.FailDownloadWith(nil) + + s.FailExistsWith(boom) + _, err = s.ObjectExists(ctx, "a.png") + require.ErrorIs(t, err, boom) + s.FailExistsWith(nil) + + exists, err := s.ObjectExists(ctx, "a.png") + require.NoError(t, err) + require.True(t, exists, "clearing the injected error should reveal the file uploaded earlier") +} diff --git a/pkg/testutil/ginctx/ginctx.go b/pkg/testutil/ginctx/ginctx.go new file mode 100644 index 00000000..874f34da --- /dev/null +++ b/pkg/testutil/ginctx/ginctx.go @@ -0,0 +1,79 @@ +// Package ginctx builds a *gin.Context for a handler test, wired up the way +// cmd/web/main.go wires a real request: a cookie-backed session under the +// name "sess". Options add what else a handler under test expects, such as +// a logged-in user or CSP nonces. +package ginctx + +import ( + "io" + "net/http/httptest" + "testing" + + "github.com/can3p/pcom/pkg/pgsession" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +// sessionName is the name gin-contrib/sessions installs the store under, +// matching cmd/web/main.go's sessions.Sessions("sess", store). +const sessionName = "sess" + +// CSP nonce context keys, matching pkg/util/ginhelpers/csp, which does not +// export them. +const ( + styleNonceKey = "csp_style_nonce" + scriptNonceKey = "csp_script_nonce" +) + +// Option configures the *gin.Context New builds, after its request and +// session are installed. +type Option func(*gin.Context) + +// WithUser makes pgsession.GetUser(c) return userID's user for the rest of +// the request, the way auth.Auth does once it reads a logged-in session. +// db must already hold a row for userID: pgsession.SetUser looks it up +// eagerly and fails the test if that lookup errors. +func WithUser(t testing.TB, db *sqlx.DB, userID string) Option { + return func(c *gin.Context) { + t.Helper() + require.NoError(t, pgsession.SetUser(c, db, userID)) + } +} + +// WithCSPNonces sets the request's CSP style/script nonces to fixed values, +// the way pkg/util/ginhelpers/csp.Csp does with random ones, so a test can +// assert on a template that renders them. +func WithCSPNonces(style, script string) Option { + return func(c *gin.Context) { + c.Set(styleNonceKey, style) + c.Set(scriptNonceKey, script) + } +} + +// New returns a *gin.Context for method/target with body as the request +// body, and the *httptest.ResponseRecorder backing its writer. The context +// carries a cookie session store under the name "sess", so handler code +// that calls sessions.Default(c) works unmodified. Apply Option values such +// as WithUser or WithCSPNonces for anything else the handler under test +// expects on the context. +func New(t testing.TB, method, target string, body io.Reader, opts ...Option) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, target, body) + + store := cookie.NewStore([]byte("ginctx-test-secret")) + sessions.Sessions(sessionName, store)(c) + + for _, opt := range opts { + opt(c) + } + + return c, w +} diff --git a/pkg/testutil/ginctx/ginctx_test.go b/pkg/testutil/ginctx/ginctx_test.go new file mode 100644 index 00000000..d5a0a478 --- /dev/null +++ b/pkg/testutil/ginctx/ginctx_test.go @@ -0,0 +1,72 @@ +package ginctx_test + +import ( + "context" + "net/http" + "testing" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/pgsession" + "github.com/can3p/pcom/pkg/testutil/ginctx" + "github.com/can3p/pcom/pkg/testutil/testdb" + "github.com/can3p/pcom/pkg/util/ginhelpers/csp" + "github.com/gin-contrib/sessions" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +func TestNew_SessionWorks(t *testing.T) { + t.Parallel() + + c, w := ginctx.New(t, http.MethodGet, "/", nil) + require.NotNil(t, w) + + sess := sessions.Default(c) + sess.Set("greeting", "hello") + require.Equal(t, "hello", sess.Get("greeting")) +} + +func TestNew_NoUserByDefault(t *testing.T) { + t.Parallel() + + c, _ := ginctx.New(t, http.MethodGet, "/", nil) + require.Nil(t, pgsession.GetUser(c)) +} + +func TestNew_WithUser(t *testing.T) { + t.Parallel() + + testDB := testdb.New(t) + ctx := context.Background() + + // Inserted directly with the generated model: pkg/testutil/factory is + // being written in parallel and this package must not depend on it. + user := &core.User{ + ID: uuid.NewString(), + Email: "ginctx-user@example.com", + Username: "ginctx-user", + Timezone: "UTC", + } + require.NoError(t, user.Insert(ctx, testDB.DB, boil.Infer())) + + c, _ := ginctx.New(t, http.MethodGet, "/", nil, ginctx.WithUser(t, testDB.DB, user.ID)) + + got := pgsession.GetUser(c) + require.NotNil(t, got) + require.Equal(t, user.ID, got.DBUser.ID) +} + +func TestNew_WithCSPNonces(t *testing.T) { + t.Parallel() + + c, _ := ginctx.New(t, http.MethodGet, "/", nil, ginctx.WithCSPNonces("style-nonce", "script-nonce")) + + stylePtr := csp.GetStyleNonce(c) + scriptPtr := csp.GetScriptNonce(c) + + require.NotNil(t, stylePtr) + require.NotNil(t, scriptPtr) + require.Equal(t, "style-nonce", *stylePtr) + require.Equal(t, "script-nonce", *scriptPtr) +} diff --git a/pkg/testutil/golden/golden.go b/pkg/testutil/golden/golden.go new file mode 100644 index 00000000..9e8aef82 --- /dev/null +++ b/pkg/testutil/golden/golden.go @@ -0,0 +1,55 @@ +// Package golden compares test output against a checked-in file, the +// convention docs/testing.md uses for mail content: run with UPDATE_GOLDEN=1 +// to write the file, and without it to check the current output still +// matches. +package golden + +import ( + "bytes" + "os" + "path/filepath" + + "github.com/can3p/pcom/pkg/testutil" +) + +// updateEnvVar is the environment variable that switches Assert from +// comparing to writing, per docs/testing.md's convention: +// +// UPDATE_GOLDEN=1 go test ./pkg/mail/... +const updateEnvVar = "UPDATE_GOLDEN" + +// Assert compares got against the golden file testdata/.golden. +// +// With UPDATE_GOLDEN set to anything non-empty, it writes got as the new +// golden file (creating testdata/ if needed) instead of comparing, and a +// test that only calls Assert cannot fail. Otherwise it fails the test when +// the file is missing or its content differs from got. +func Assert(t testutil.TB, name string, got []byte) { + t.Helper() + + path := filepath.Join("testdata", name+".golden") + + if os.Getenv(updateEnvVar) != "" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("golden: creating %s: %v", filepath.Dir(path), err) + return + } + + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatalf("golden: writing %s: %v", path, err) + } + + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("golden: reading %s: %v (run with %s=1 to create it)", path, err, updateEnvVar) + return + } + + if !bytes.Equal(want, got) { + t.Fatalf("golden: %s does not match; run with %s=1 to update it\n--- want ---\n%s\n--- got ---\n%s", + path, updateEnvVar, want, got) + } +} diff --git a/pkg/testutil/golden/golden_test.go b/pkg/testutil/golden/golden_test.go new file mode 100644 index 00000000..f7a9c8ca --- /dev/null +++ b/pkg/testutil/golden/golden_test.go @@ -0,0 +1,75 @@ +package golden_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/can3p/pcom/pkg/testutil/golden" + "github.com/stretchr/testify/require" +) + +func TestAssert_WritesWithUpdateGolden(t *testing.T) { + // Not t.Parallel(): shares UPDATE_GOLDEN and a testdata file with the + // other tests in this file. + const name = "selftest-write" + path := filepath.Join("testdata", name+".golden") + t.Cleanup(func() { _ = os.Remove(path) }) + + t.Setenv("UPDATE_GOLDEN", "1") + golden.Assert(t, name, []byte("hello golden")) + + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "hello golden", string(got)) +} + +func TestAssert_MatchesAndMismatches(t *testing.T) { + const name = "selftest-compare" + path := filepath.Join("testdata", name+".golden") + t.Cleanup(func() { _ = os.Remove(path) }) + + t.Setenv("UPDATE_GOLDEN", "1") + golden.Assert(t, name, []byte("expected content")) + + t.Setenv("UPDATE_GOLDEN", "") + golden.Assert(t, name, []byte("expected content")) // matches: must not fail + + ft := &fakeTB{} + done := make(chan struct{}) + go func() { + defer close(done) + golden.Assert(ft, name, []byte("different content")) + }() + <-done + + require.True(t, ft.failed, "Assert should fail the test on a mismatch") +} + +func TestAssert_FailsOnMissingFile(t *testing.T) { + t.Setenv("UPDATE_GOLDEN", "") + + ft := &fakeTB{} + done := make(chan struct{}) + go func() { + defer close(done) + golden.Assert(ft, "selftest-does-not-exist", []byte("anything")) + }() + <-done + + require.True(t, ft.failed, "Assert should fail the test when the golden file is missing") +} + +// fakeTB records a Fatalf call instead of failing the real test, so a test +// can assert that Assert's failure path runs without failing itself. +type fakeTB struct { + failed bool +} + +func (f *fakeTB) Helper() {} + +func (f *fakeTB) Fatalf(format string, args ...any) { + f.failed = true + runtime.Goexit() +} diff --git a/pkg/testutil/must.go b/pkg/testutil/must.go new file mode 100644 index 00000000..62f4560e --- /dev/null +++ b/pkg/testutil/must.go @@ -0,0 +1,28 @@ +// Package testutil holds the smallest, most widely used test helper: +// Must. Everything else (fakes, gin contexts, golden files, a test +// database) lives in its own subpackage so a test imports only what it +// needs. +package testutil + +// TB is the subset of testing.TB that Must needs. *testing.T and *testing.B +// satisfy it. A test that wants to assert on Must's own failure path (as +// opposed to using Must to set up a fixture) can pass a fake that records +// the call instead of stopping the real test. +type TB interface { + Helper() + Fatalf(format string, args ...any) +} + +// Must returns v when err is nil. Otherwise it fails the test immediately, +// the way t.Fatalf would. It is for one-line fixture setup: +// +// body := testutil.Must(t, os.ReadFile(path)) +func Must[T any](t TB, v T, err error) T { + t.Helper() + + if err != nil { + t.Fatalf("testutil.Must: unexpected error: %v", err) + } + + return v +} diff --git a/pkg/testutil/must_test.go b/pkg/testutil/must_test.go new file mode 100644 index 00000000..b6224f47 --- /dev/null +++ b/pkg/testutil/must_test.go @@ -0,0 +1,48 @@ +package testutil_test + +import ( + "errors" + "runtime" + "testing" + + "github.com/can3p/pcom/pkg/testutil" + "github.com/stretchr/testify/require" +) + +func TestMust_PassesThroughOnSuccess(t *testing.T) { + t.Parallel() + + got := testutil.Must(t, 42, nil) + require.Equal(t, 42, got) +} + +func TestMust_FailsTestOnError(t *testing.T) { + t.Parallel() + + ft := &fakeTB{} + done := make(chan struct{}) + + go func() { + defer close(done) + testutil.Must(ft, 0, errors.New("boom")) + ft.reachedAfter = true + }() + <-done + + require.True(t, ft.failed, "Must should have called Fatalf") + require.False(t, ft.reachedAfter, "Fatalf should stop the goroutine, like testing.T's does") +} + +// fakeTB records a Fatalf call instead of failing the real test, so a test +// can assert that Must's failure path runs without failing itself. +type fakeTB struct { + failed bool + reachedAfter bool +} + +func (f *fakeTB) Helper() {} + +func (f *fakeTB) Fatalf(format string, args ...any) { + f.failed = true + runtime.Goexit() +} From 0896e826258e9897f7e6b03343e3325c6cdcad1d Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:07:59 +0200 Subject: [PATCH 04/14] W0.T0.5: coverage targets and Codecov config `make test` writes coverage.out for CI's Codecov upload. `make cover` collects unit and E2E binary coverage into one directory and prints a per-package table without the generated pkg/model/core. `make test-short` skips E2E. codecov.yml ignores pkg/model/core. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- .gitignore | 2 ++ Makefile | 16 ++++++++++++++-- codecov.yml | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 codecov.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a0bb7d28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.cover/ +coverage.out diff --git a/Makefile b/Makefile index 86ce9117..56d957a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: shell tunnel lint test build check fix check-q test-q vet-q cover-q model +.PHONY: shell tunnel lint test test-short cover build check fix check-q test-q vet-q cover-q model PKG ?= ./... @@ -18,7 +18,19 @@ lint: golangci-lint run ./... --timeout=5m test: - go test ./... + go test -coverprofile=coverage.out ./... + +test-short: + go test -short ./... + +COVDIR := $(CURDIR)/.cover + +cover: + @rm -rf $(COVDIR) + @mkdir -p $(COVDIR) + @GOCOVERDIR=$(COVDIR) go test -cover ./... -args -test.gocoverdir=$(COVDIR) + @go tool covdata percent -i=$(COVDIR) | perl -pe 's/\t\t\t/\n/g' | grep "coverage:" | grep -v github.com/can3p/pcom/pkg/model/core + @go tool covdata textfmt -i=$(COVDIR) -o coverage.out build: go build -v ./... diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..14cd8993 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "pkg/model/core/**" From 2f8b78cfc8ecb73f39051b250aff3f216d5d1c94 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:14:20 +0200 Subject: [PATCH 05/14] W0.T0.2: test factories One builder per table plus the relationships the domain needs (users and the connection graph, posts and comments, account, feeds, media, mail), with defaults that satisfy every constraint and stay unique across a run, and readers in read.go. The package doesn't import testing, so the seed command can use it. A self-test builds every entity once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- pkg/testutil/factory/account.go | 126 ++++++++++++++++ pkg/testutil/factory/factory_test.go | 165 ++++++++++++++++++++ pkg/testutil/factory/feed.go | 147 ++++++++++++++++++ pkg/testutil/factory/ids.go | 37 +++++ pkg/testutil/factory/media.go | 62 ++++++++ pkg/testutil/factory/post.go | 218 +++++++++++++++++++++++++++ pkg/testutil/factory/read.go | 55 +++++++ pkg/testutil/factory/user.go | 145 ++++++++++++++++++ 8 files changed, 955 insertions(+) create mode 100644 pkg/testutil/factory/account.go create mode 100644 pkg/testutil/factory/factory_test.go create mode 100644 pkg/testutil/factory/feed.go create mode 100644 pkg/testutil/factory/ids.go create mode 100644 pkg/testutil/factory/media.go create mode 100644 pkg/testutil/factory/post.go create mode 100644 pkg/testutil/factory/read.go create mode 100644 pkg/testutil/factory/user.go diff --git a/pkg/testutil/factory/account.go b/pkg/testutil/factory/account.go new file mode 100644 index 00000000..fb1b14ff --- /dev/null +++ b/pkg/testutil/factory/account.go @@ -0,0 +1,126 @@ +package factory + +import ( + "context" + "fmt" + "time" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/google/uuid" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// InvitationOpt customizes a UserInvitation before it is inserted. +type InvitationOpt func(*core.UserInvitation) + +// Sent marks the invitation as already emailed to the given address. +func Sent(email string) InvitationOpt { + return func(i *core.UserInvitation) { + i.InvitationEmail = null.StringFrom(email) + i.InvitationSentAt = null.TimeFrom(time.Now()) + } +} + +// Invitation inserts one of userID's invitation slots. +func Invitation(ctx context.Context, exec boil.ContextExecutor, userID string, opts ...InvitationOpt) (*core.UserInvitation, error) { + id, err := newID() + if err != nil { + return nil, err + } + + i := &core.UserInvitation{ + ID: id, + UserID: userID, + } + + for _, opt := range opts { + opt(i) + } + + if err := i.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return i, nil +} + +// SignupRequest inserts a pending request to join, with a unique email. +func SignupRequest(ctx context.Context, exec boil.ContextExecutor) (*core.UserSignupRequest, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + r := &core.UserSignupRequest{ + ID: id, + Email: fmt.Sprintf("signup%d@example.test", n), + } + + if err := r.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return r, nil +} + +// APIKey issues userID a fresh API key. +func APIKey(ctx context.Context, exec boil.ContextExecutor, userID string) (*core.UserAPIKey, error) { + id, err := newID() + if err != nil { + return nil, err + } + + key, err := uuid.NewV7() + if err != nil { + return nil, err + } + + k := &core.UserAPIKey{ + ID: id, + APIKey: key.String(), + UserID: userID, + } + + if err := k.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return k, nil +} + +// UserStyle sets userID's custom profile CSS. +func UserStyle(ctx context.Context, exec boil.ContextExecutor, userID string, css string) (*core.UserStyle, error) { + id, err := newID() + if err != nil { + return nil, err + } + + s := &core.UserStyle{ + ID: id, + UserID: userID, + Styles: css, + } + + if err := s.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return s, nil +} + +// SetRegistrationOpen flips the singleton system setting that gates signup. +func SetRegistrationOpen(ctx context.Context, exec boil.ContextExecutor, open bool) error { + settings, err := core.SystemSettings().One(ctx, exec) + if err != nil { + return err + } + + settings.RegistrationOpen = open + + _, err = settings.Update(ctx, exec, boil.Whitelist(core.SystemSettingColumns.RegistrationOpen)) + + return err +} diff --git a/pkg/testutil/factory/factory_test.go b/pkg/testutil/factory/factory_test.go new file mode 100644 index 00000000..c127285a --- /dev/null +++ b/pkg/testutil/factory/factory_test.go @@ -0,0 +1,165 @@ +package factory_test + +import ( + "context" + "testing" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/can3p/pcom/pkg/testutil/testdb" + "github.com/stretchr/testify/require" +) + +// TestFactoriesBuildEveryEntity builds one of every entity the factory +// package offers, proving that every default satisfies the schema and that +// every documented option works. +func TestFactoriesBuildEveryEntity(t *testing.T) { + t.Parallel() + + db := testdb.New(t).DB + ctx := context.Background() + + // Users and graph. + alice, err := factory.User(ctx, db) + require.NoError(t, err) + + bob, err := factory.User(ctx, db, + factory.WithPassword("s3cret-password"), + factory.WithVisibility(core.ProfileVisibilityPublic), + ) + require.NoError(t, err) + require.True(t, bob.Pwdhash.Valid) + require.Equal(t, core.ProfileVisibilityPublic, bob.ProfileVisibility) + + carol, err := factory.User(ctx, db) + require.NoError(t, err) + + _, _, err = factory.Connect(ctx, db, alice.ID, bob.ID) + require.NoError(t, err) + _, _, err = factory.Connect(ctx, db, alice.ID, carol.ID) + require.NoError(t, err) + + _, err = factory.Whitelist(ctx, db, alice.ID, carol.ID) + require.NoError(t, err) + + mediationRequest, err := factory.MediationRequest(ctx, db, carol.ID, bob.ID, + factory.WithSourceNote("we have a friend in common"), + ) + require.NoError(t, err) + + _, err = factory.MediatorDecision(ctx, db, mediationRequest.ID, alice.ID, core.ConnectionMediationDecisionSigned) + require.NoError(t, err) + + // Posts. + url, err := factory.NormalizedURL(ctx, db) + require.NoError(t, err) + + post, err := factory.Post(ctx, db, alice.ID, + factory.Published(), + factory.Visibility(core.PostVisibilityPublic), + factory.WithURL(url.ID), + ) + require.NoError(t, err) + require.True(t, post.PublishedAt.Valid) + require.Equal(t, core.PostVisibilityPublic, post.VisibilityRadius) + require.True(t, post.URLID.Valid) + + draft, err := factory.Post(ctx, db, bob.ID) + require.NoError(t, err) + require.False(t, draft.PublishedAt.Valid) + + topComment, err := factory.Comment(ctx, db, post.ID, bob.ID) + require.NoError(t, err) + + reply, err := factory.Comment(ctx, db, post.ID, carol.ID, factory.ReplyTo(topComment.ID)) + require.NoError(t, err) + require.Equal(t, topComment.ID, reply.TopCommentID) + + _, err = factory.PostStat(ctx, db, post.ID) + require.NoError(t, err) + + _, err = factory.PostShare(ctx, db, post.ID) + require.NoError(t, err) + + _, err = factory.PostPrompt(ctx, db, alice.ID, bob.ID, + factory.WithPost(post.ID), + factory.Dismissed(), + ) + require.NoError(t, err) + + // Account. + _, err = factory.Invitation(ctx, db, alice.ID, factory.Sent("invitee@example.test")) + require.NoError(t, err) + + _, err = factory.SignupRequest(ctx, db) + require.NoError(t, err) + + _, err = factory.APIKey(ctx, db, alice.ID) + require.NoError(t, err) + + _, err = factory.UserStyle(ctx, db, alice.ID, "body { color: red; }") + require.NoError(t, err) + + require.NoError(t, factory.SetRegistrationOpen(ctx, db, false)) + + // Feeds. + feed, err := factory.RSSFeed(ctx, db) + require.NoError(t, err) + + _, err = factory.Subscription(ctx, db, alice.ID, feed.ID) + require.NoError(t, err) + + item, err := factory.RSSItem(ctx, db, feed.ID) + require.NoError(t, err) + + _, err = factory.UserFeedItem(ctx, db, alice.ID, item.ID, factory.IsDismissed()) + require.NoError(t, err) + + item2, err := factory.RSSItem(ctx, db, feed.ID, factory.WithURLID(url.ID)) + require.NoError(t, err) + require.Equal(t, url.ID, item2.URLID) + + // Media and mail. + _, err = factory.MediaUpload(ctx, db, alice.ID) + require.NoError(t, err) + + _, err = factory.OutgoingEmail(ctx, db, "test_email") + require.NoError(t, err) + + // Readers. + gotUser, err := factory.GetUser(ctx, db, alice.ID) + require.NoError(t, err) + require.Equal(t, alice.ID, gotUser.ID) + + gotPost, err := factory.GetPost(ctx, db, post.ID) + require.NoError(t, err) + require.Equal(t, post.ID, gotPost.ID) + + posts, err := factory.ListPosts(ctx, db, alice.ID) + require.NoError(t, err) + require.Len(t, posts, 1) + + comments, err := factory.ListComments(ctx, db, post.ID) + require.NoError(t, err) + require.Len(t, comments, 2) + + emails, err := factory.ListOutgoingEmails(ctx, db, core.OutgoingEmailWhere.EmailType.EQ("test_email")) + require.NoError(t, err) + require.Len(t, emails, 1) + + connected, err := factory.ConnectionExists(ctx, db, alice.ID, bob.ID) + require.NoError(t, err) + require.True(t, connected) + + connected, err = factory.ConnectionExists(ctx, db, bob.ID, alice.ID) + require.NoError(t, err) + require.True(t, connected) + + connected, err = factory.ConnectionExists(ctx, db, bob.ID, carol.ID) + require.NoError(t, err) + require.False(t, connected) + + gotRequest, err := factory.GetMediationRequest(ctx, db, mediationRequest.ID) + require.NoError(t, err) + require.Equal(t, mediationRequest.ID, gotRequest.ID) +} diff --git a/pkg/testutil/factory/feed.go b/pkg/testutil/factory/feed.go new file mode 100644 index 00000000..0668f581 --- /dev/null +++ b/pkg/testutil/factory/feed.go @@ -0,0 +1,147 @@ +package factory + +import ( + "context" + "fmt" + "time" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// RSSFeed inserts a feed with a unique URL. +func RSSFeed(ctx context.Context, exec boil.ContextExecutor) (*core.RSSFeed, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + f := &core.RSSFeed{ + ID: id, + URL: fmt.Sprintf("https://example.test/feed/%d.xml", n), + Title: null.StringFrom(fmt.Sprintf("Test feed %d", n)), + Description: null.StringFrom("Test feed description"), + UpdateFrequencyMinutes: 60, + } + + if err := f.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return f, nil +} + +// Subscription subscribes userID to feedID. +func Subscription(ctx context.Context, exec boil.ContextExecutor, userID, feedID string) (*core.UserFeedSubscription, error) { + id, err := newID() + if err != nil { + return nil, err + } + + s := &core.UserFeedSubscription{ + ID: id, + UserID: userID, + FeedID: feedID, + } + + if err := s.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return s, nil +} + +// RSSItemOpt customizes an RSSItem before it is inserted. +type RSSItemOpt func(*core.RSSItem) + +// WithURLID attaches the item to an already-created NormalizedURL instead of +// a freshly made-up one. +func WithURLID(urlID string) RSSItemOpt { + return func(i *core.RSSItem) { + i.URLID = urlID + } +} + +// RSSItem inserts an item published just now into feedID, making up a +// NormalizedURL for it unless WithURLID overrides that. +func RSSItem(ctx context.Context, exec boil.ContextExecutor, feedID string, opts ...RSSItemOpt) (*core.RSSItem, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + item := &core.RSSItem{ + ID: id, + FeedID: feedID, + GUID: fmt.Sprintf("test-guid-%d", n), + Title: fmt.Sprintf("Test item %d", n), + Description: "Test item description", + SanitizedDescription: "Test item description", + PublishedAt: time.Now(), + } + + for _, opt := range opts { + opt(item) + } + + if item.URLID == "" { + url, err := NormalizedURL(ctx, exec) + if err != nil { + return nil, err + } + + item.URLID = url.ID + } + + if err := item.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return item, nil +} + +// UserFeedItemOpt customizes a UserFeedItem before it is inserted. +type UserFeedItemOpt func(*core.UserFeedItem) + +// IsDismissed marks the feed item as already dismissed by the user. +func IsDismissed() UserFeedItemOpt { + return func(i *core.UserFeedItem) { + i.IsDismissed = true + } +} + +// UserFeedItem inserts rssItemID into userID's feed, looking up the item's +// URL so a caller does not have to pass it separately. +func UserFeedItem(ctx context.Context, exec boil.ContextExecutor, userID, rssItemID string, opts ...UserFeedItemOpt) (*core.UserFeedItem, error) { + id, err := newID() + if err != nil { + return nil, err + } + + item, err := core.FindRSSItem(ctx, exec, rssItemID) + if err != nil { + return nil, err + } + + i := &core.UserFeedItem{ + ID: id, + UserID: userID, + RSSItemID: rssItemID, + URLID: item.URLID, + } + + for _, opt := range opts { + opt(i) + } + + if err := i.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return i, nil +} diff --git a/pkg/testutil/factory/ids.go b/pkg/testutil/factory/ids.go new file mode 100644 index 00000000..dd00d8f0 --- /dev/null +++ b/pkg/testutil/factory/ids.go @@ -0,0 +1,37 @@ +// Package factory builds valid rows for pcom's tables so that tests (and +// cmd/seed) don't have to know the schema. Every builder inserts through the +// ORM and returns the row it created; defaults satisfy every constraint and +// are unique across a run, so a test only overrides what it cares about. +// +// Tests reach the ORM only through this package (see docs/testing.md): a +// test body should not call core.Posts(...) or similar directly. +// +// This package must not import "testing": cmd/seed links it into a normal +// binary, not a test binary. +package factory + +import ( + "sync/atomic" + + "github.com/google/uuid" +) + +// counter hands out small increasing integers so that every made-up value +// (an email, a username, a URL, ...) is unique across a whole test run +// without a caller having to think about it. +var counter int64 + +func next() int64 { + return atomic.AddInt64(&counter, 1) +} + +// newID mints a time-ordered UUID for a new row's primary key, the same way +// pcom's own write paths do (see e.g. userops.CreateConnection). +func newID() (string, error) { + id, err := uuid.NewV7() + if err != nil { + return "", err + } + + return id.String(), nil +} diff --git a/pkg/testutil/factory/media.go b/pkg/testutil/factory/media.go new file mode 100644 index 00000000..613aecd2 --- /dev/null +++ b/pkg/testutil/factory/media.go @@ -0,0 +1,62 @@ +package factory + +import ( + "context" + "fmt" + "time" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// MediaUpload records an image uploaded by userID. +func MediaUpload(ctx context.Context, exec boil.ContextExecutor, userID string) (*core.MediaUpload, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + m := &core.MediaUpload{ + ID: id, + UserID: null.StringFrom(userID), + UploadedFname: fmt.Sprintf("test-upload-%d.png", n), + ContentType: "image/png", + } + + if err := m.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return m, nil +} + +// OutgoingEmail schedules a queued email of the given type. +func OutgoingEmail(ctx context.Context, exec boil.ContextExecutor, emailType string) (*core.OutgoingEmail, error) { + id, err := newID() + if err != nil { + return nil, err + } + + uniqueID, err := newID() + if err != nil { + return nil, err + } + + e := &core.OutgoingEmail{ + ID: id, + UniqueID: uniqueID, + Payload: []byte("{}"), + Status: core.OutgoingEmailStatusNew, + TryAt: time.Now(), + EmailType: emailType, + } + + if err := e.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return e, nil +} diff --git a/pkg/testutil/factory/post.go b/pkg/testutil/factory/post.go new file mode 100644 index 00000000..4abbc7d6 --- /dev/null +++ b/pkg/testutil/factory/post.go @@ -0,0 +1,218 @@ +package factory + +import ( + "context" + "fmt" + "time" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// PostOpt customizes a Post before it is inserted. +type PostOpt func(*core.Post) + +// Published marks the post as published now. A Post is an unpublished draft +// by default. +func Published() PostOpt { + return func(p *core.Post) { + p.PublishedAt = null.TimeFrom(time.Now()) + } +} + +// Visibility overrides the post's visibility radius (direct_only by +// default). +func Visibility(v core.PostVisibility) PostOpt { + return func(p *core.Post) { + p.VisibilityRadius = v + } +} + +// WithURL attaches the post to an already-created NormalizedURL. +func WithURL(urlID string) PostOpt { + return func(p *core.Post) { + p.URLID = null.StringFrom(urlID) + } +} + +// Post inserts a draft, direct_only post owned by authorID. +func Post(ctx context.Context, exec boil.ContextExecutor, authorID string, opts ...PostOpt) (*core.Post, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + p := &core.Post{ + ID: id, + Subject: null.StringFrom(fmt.Sprintf("Test post %d", n)), + Body: fmt.Sprintf("Test post body %d", n), + UserID: authorID, + VisibilityRadius: core.PostVisibilityDirectOnly, + } + + for _, opt := range opts { + opt(p) + } + + if err := p.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return p, nil +} + +// CommentOpt customizes a PostComment before it is inserted. +type CommentOpt func(*core.PostComment) + +// ReplyTo makes the new comment a reply to the existing comment commentID, +// inheriting its thread's top comment the way the comment form does. +func ReplyTo(commentID string) CommentOpt { + return func(c *core.PostComment) { + c.ParentCommentID = null.StringFrom(commentID) + } +} + +// Comment inserts a top-level comment on postID by authorID, or a reply +// when ReplyTo is given. +func Comment(ctx context.Context, exec boil.ContextExecutor, postID, authorID string, opts ...CommentOpt) (*core.PostComment, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + c := &core.PostComment{ + ID: id, + UserID: authorID, + PostID: postID, + Body: fmt.Sprintf("Test comment %d", n), + TopCommentID: id, + } + + for _, opt := range opts { + opt(c) + } + + if c.ParentCommentID.Valid { + parent, err := core.FindPostComment(ctx, exec, c.ParentCommentID.String) + if err != nil { + return nil, err + } + + c.TopCommentID = parent.TopCommentID + } + + if err := c.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return c, nil +} + +// PostStat inserts the cached stats row (one per post) for postID. +func PostStat(ctx context.Context, exec boil.ContextExecutor, postID string) (*core.PostStat, error) { + id, err := newID() + if err != nil { + return nil, err + } + + s := &core.PostStat{ + ID: id, + PostID: postID, + } + + if err := s.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return s, nil +} + +// PostShare marks postID as shared (one row per post). +func PostShare(ctx context.Context, exec boil.ContextExecutor, postID string) (*core.PostShare, error) { + id, err := newID() + if err != nil { + return nil, err + } + + s := &core.PostShare{ + ID: id, + PostID: postID, + } + + if err := s.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return s, nil +} + +// PostPromptOpt customizes a PostPrompt before it is inserted. +type PostPromptOpt func(*core.PostPrompt) + +// WithPost attaches the prompt to the post written in answer to it. +func WithPost(postID string) PostPromptOpt { + return func(p *core.PostPrompt) { + p.PostID = null.StringFrom(postID) + } +} + +// Dismissed marks the prompt as dismissed by its recipient. +func Dismissed() PostPromptOpt { + return func(p *core.PostPrompt) { + p.DismissedAt = null.TimeFrom(time.Now()) + } +} + +// PostPrompt inserts askerID's prompt asking recipientID to write about +// something. +func PostPrompt(ctx context.Context, exec boil.ContextExecutor, askerID, recipientID string, opts ...PostPromptOpt) (*core.PostPrompt, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + p := &core.PostPrompt{ + ID: id, + AskerID: askerID, + RecipientID: recipientID, + Message: fmt.Sprintf("Test prompt %d", n), + } + + for _, opt := range opts { + opt(p) + } + + if err := p.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return p, nil +} + +// NormalizedURL inserts a made-up, unique URL. +func NormalizedURL(ctx context.Context, exec boil.ContextExecutor) (*core.NormalizedURL, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + u := &core.NormalizedURL{ + ID: id, + URL: fmt.Sprintf("https://example.test/url/%d", n), + } + + if err := u.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return u, nil +} diff --git a/pkg/testutil/factory/read.go b/pkg/testutil/factory/read.go new file mode 100644 index 00000000..4c369174 --- /dev/null +++ b/pkg/testutil/factory/read.go @@ -0,0 +1,55 @@ +package factory + +import ( + "context" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" +) + +// GetUser looks up a user by id. +func GetUser(ctx context.Context, exec boil.ContextExecutor, id string) (*core.User, error) { + return core.FindUser(ctx, exec, id) +} + +// GetPost looks up a post by id. +func GetPost(ctx context.Context, exec boil.ContextExecutor, id string) (*core.Post, error) { + return core.FindPost(ctx, exec, id) +} + +// ListPosts returns every post owned by userID. +func ListPosts(ctx context.Context, exec boil.ContextExecutor, userID string) (core.PostSlice, error) { + return core.Posts(core.PostWhere.UserID.EQ(userID)).All(ctx, exec) +} + +// ListComments returns every comment on postID. +func ListComments(ctx context.Context, exec boil.ContextExecutor, postID string) (core.PostCommentSlice, error) { + return core.PostComments(core.PostCommentWhere.PostID.EQ(postID)).All(ctx, exec) +} + +// ListOutgoingEmails returns the queued emails matching filter, e.g. +// factory.ListOutgoingEmails(ctx, db, core.OutgoingEmailWhere.EmailType.EQ("welcome")). +func ListOutgoingEmails(ctx context.Context, exec boil.ContextExecutor, filter ...qm.QueryMod) (core.OutgoingEmailSlice, error) { + return core.OutgoingEmails(filter...).All(ctx, exec) +} + +// ConnectionExists reports whether aID and bID are directly connected, in +// either direction. +func ConnectionExists(ctx context.Context, exec boil.ContextExecutor, aID, bID string) (bool, error) { + return core.UserConnections( + qm.Expr( + core.UserConnectionWhere.User1ID.EQ(aID), + core.UserConnectionWhere.User2ID.EQ(bID), + ), + qm.Or2(qm.Expr( + core.UserConnectionWhere.User1ID.EQ(bID), + core.UserConnectionWhere.User2ID.EQ(aID), + )), + ).Exists(ctx, exec) +} + +// GetMediationRequest looks up a mediation request by id. +func GetMediationRequest(ctx context.Context, exec boil.ContextExecutor, id string) (*core.UserConnectionMediationRequest, error) { + return core.FindUserConnectionMediationRequest(ctx, exec, id) +} diff --git a/pkg/testutil/factory/user.go b/pkg/testutil/factory/user.go new file mode 100644 index 00000000..c6307af6 --- /dev/null +++ b/pkg/testutil/factory/user.go @@ -0,0 +1,145 @@ +package factory + +import ( + "context" + "fmt" + "time" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/can3p/pcom/pkg/pgsession" + "github.com/can3p/pcom/pkg/userops" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +// UserOpt customizes a User before it is inserted. +type UserOpt func(*core.User) + +// WithPassword sets a login password: it hashes it the way the app does and +// marks the user's email confirmed. +func WithPassword(pw string) UserOpt { + return func(u *core.User) { + u.Pwdhash = null.StringFrom(pgsession.HashUserPwd(u.Email, pw)) + u.EmailConfirmedAt = null.TimeFrom(time.Now()) + } +} + +// WithVisibility overrides the user's default profile visibility. +func WithVisibility(v core.ProfileVisibility) UserOpt { + return func(u *core.User) { + u.ProfileVisibility = v + } +} + +// User inserts a confirmed user with a unique email/username pair +// (e.g. user7@example.test / user7) in the UTC timezone. +func User(ctx context.Context, exec boil.ContextExecutor, opts ...UserOpt) (*core.User, error) { + id, err := newID() + if err != nil { + return nil, err + } + + n := next() + + u := &core.User{ + ID: id, + Email: fmt.Sprintf("user%d@example.test", n), + Username: fmt.Sprintf("user%d", n), + Timezone: "UTC", + EmailConfirmedAt: null.TimeFrom(time.Now()), + } + + for _, opt := range opts { + opt(u) + } + + if err := u.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return u, nil +} + +// Connect makes aID and bID direct connections, inserting both directed +// rows the way userops.CreateConnection does. +func Connect(ctx context.Context, exec boil.ContextExecutor, aID, bID string) (*core.UserConnection, *core.UserConnection, error) { + return userops.CreateConnection(ctx, exec, aID, bID) +} + +// Whitelist lets allowsWhoID connect to whoID without mediation. +func Whitelist(ctx context.Context, exec boil.ContextExecutor, whoID, allowsWhoID string) (*core.WhitelistedConnection, error) { + id, err := newID() + if err != nil { + return nil, err + } + + wl := &core.WhitelistedConnection{ + ID: id, + WhoID: whoID, + AllowsWhoID: allowsWhoID, + } + + if err := wl.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return wl, nil +} + +// MediationRequestOpt customizes a mediation request before it is inserted. +type MediationRequestOpt func(*core.UserConnectionMediationRequest) + +// WithSourceNote sets the note the requester attaches to the request. +func WithSourceNote(note string) MediationRequestOpt { + return func(r *core.UserConnectionMediationRequest) { + r.SourceNote = null.StringFrom(note) + } +} + +// MediationRequest records whoID asking to be connected to targetID through +// a common connection. +func MediationRequest(ctx context.Context, exec boil.ContextExecutor, whoID, targetID string, opts ...MediationRequestOpt) (*core.UserConnectionMediationRequest, error) { + id, err := newID() + if err != nil { + return nil, err + } + + r := &core.UserConnectionMediationRequest{ + ID: id, + WhoUserID: whoID, + TargetUserID: targetID, + } + + for _, opt := range opts { + opt(r) + } + + if err := r.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return r, nil +} + +// MediatorDecision records mediatorID's decision on the mediation request +// requestID. +func MediatorDecision(ctx context.Context, exec boil.ContextExecutor, requestID, mediatorID string, decision core.ConnectionMediationDecision) (*core.UserConnectionMediator, error) { + id, err := newID() + if err != nil { + return nil, err + } + + m := &core.UserConnectionMediator{ + ID: id, + MediationID: requestID, + UserID: mediatorID, + Decision: decision, + DecidedAt: time.Now(), + } + + if err := m.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return m, nil +} From f115b8d792c1fd2895d54b332360cccc8f6ac6d8 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:14:20 +0200 Subject: [PATCH 06/14] W0.T0.4: end-to-end harness running the real binary e2e.Main builds ./cmd/web once with coverage; e2e.Start runs it against a fresh database in a work dir with a stub asset manifest and waits for GET / to answer. App.Client is a cookie-jar client that doesn't follow redirects, sends the scraped CSRF token and exposes htmx headers; HTML assertions use goquery (new test-only dependency). The server has no signal handling, so a build overlay adds a SIGTERM handler to pkg/types (a package without statements, left out of -coverpkg because the cover tool ignores overlays). A stopped server then exits normally and writes its coverage where `make cover` merges it. Smoke tests: anonymous GET / and a factory user logging in to /feed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- e2e/client.go | 217 ++++++++++++++++++ e2e/harness.go | 391 ++++++++++++++++++++++++++++++++ e2e/main_test.go | 9 + e2e/smoke_test.go | 32 +++ e2e/testdata/exit_on_sigterm.go | 26 +++ go.mod | 4 +- go.sum | 8 +- 7 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 e2e/client.go create mode 100644 e2e/harness.go create mode 100644 e2e/main_test.go create mode 100644 e2e/smoke_test.go create mode 100644 e2e/testdata/exit_on_sigterm.go diff --git a/e2e/client.go b/e2e/client.go new file mode 100644 index 00000000..7789cea3 --- /dev/null +++ b/e2e/client.go @@ -0,0 +1,217 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + + "github.com/PuerkitoBio/goquery" +) + +// Client is a browser-like HTTP client for one App: it keeps cookies, does +// not follow redirects (tests assert on them), and sends the CSRF token that +// pages carry in , as htmx does. +type Client struct { + t testing.TB + app *App + http *http.Client + csrf string +} + +// Client returns a new client with an empty cookie jar, so an anonymous +// visitor until LoginAs. +func (a *App) Client(t testing.TB) *Client { + t.Helper() + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + + return &Client{ + t: t, + app: a, + http: &http.Client{ + Jar: jar, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +// Get requests path, such as "/feed". +func (c *Client) Get(path string) *Response { + c.t.Helper() + + req, err := http.NewRequest(http.MethodGet, c.app.URL+path, nil) + if err != nil { + c.t.Fatal(err) + } + + return c.Do(req) +} + +// PostForm posts url-encoded form values, as a plain htmx form does. +func (c *Client) PostForm(path string, form url.Values) *Response { + c.t.Helper() + + req, err := http.NewRequest(http.MethodPost, c.app.URL+path, strings.NewReader(form.Encode())) + if err != nil { + c.t.Fatal(err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return c.post(req) +} + +// PostJSON posts v as JSON, as htmx's json-enc extension does for the +// action controller: c.PostJSON("/controls/action/", payload). +func (c *Client) PostJSON(path string, v any) *Response { + c.t.Helper() + + body, err := json.Marshal(v) + if err != nil { + c.t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodPost, c.app.URL+path, bytes.NewReader(body)) + if err != nil { + c.t.Fatal(err) + } + + req.Header.Set("Content-Type", "application/json") + + return c.post(req) +} + +// LoginAs logs in through the login form and fails the test if the login is +// rejected. +func (c *Client) LoginAs(email, password string) { + c.t.Helper() + + c.Get("/login").RequireStatus(http.StatusOK) + + resp := c.PostForm("/form/login", url.Values{"email": {email}, "password": {password}}) + resp.RequireStatus(http.StatusOK) + + if resp.HXRedirect() == "" { + c.t.Fatalf("e2e: login as %s was rejected:\n%s", email, resp.Body) + } +} + +// Do sends req as htmx would: with the HX-Request header and, on anything +// but GET, the CSRF token. +func (c *Client) Do(req *http.Request) *Response { + c.t.Helper() + + req.Header.Set("HX-Request", "true") + + if req.Method != http.MethodGet && c.csrf != "" { + req.Header.Set("X-CSRFToken", c.csrf) + } + + resp, err := c.http.Do(req) + if err != nil { + c.t.Fatalf("e2e: %s %s: %v", req.Method, req.URL.Path, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + c.t.Fatal(err) + } + + out := &Response{t: c.t, StatusCode: resp.StatusCode, Header: resp.Header, Body: string(body)} + c.rememberCSRF(out) + + return out +} + +// post makes sure a CSRF token is known before a mutating request: a fresh +// client loads the home page first, as a visitor's browser would have. +func (c *Client) post(req *http.Request) *Response { + c.t.Helper() + + if c.csrf == "" { + c.Get("/") + } + + return c.Do(req) +} + +func (c *Client) rememberCSRF(r *Response) { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "text/html") { + return + } + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(r.Body)) + if err != nil { + return + } + + raw, ok := doc.Find("body").Attr("hx-headers") + if !ok { + return + } + + var headers map[string]string + if err := json.Unmarshal([]byte(raw), &headers); err == nil && headers["X-CSRFToken"] != "" { + c.csrf = headers["X-CSRFToken"] + } +} + +// Response is a fully read HTTP response. +type Response struct { + t testing.TB + StatusCode int + Header http.Header + Body string +} + +// RequireStatus fails the test unless the response has the given status. +func (r *Response) RequireStatus(code int) *Response { + r.t.Helper() + + if r.StatusCode != code { + r.t.Fatalf("e2e: status %d, want %d; body:\n%s", r.StatusCode, code, r.Body) + } + + return r +} + +// Doc parses the body as HTML for goquery CSS selectors. +func (r *Response) Doc() *goquery.Document { + r.t.Helper() + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(r.Body)) + if err != nil { + r.t.Fatal(err) + } + + return doc +} + +// Location is the redirect target of a 3xx response. +func (r *Response) Location() string { return r.Header.Get("Location") } + +// HXRedirect is the htmx client-side redirect target. +func (r *Response) HXRedirect() string { return r.Header.Get("HX-Redirect") } + +// HXTrigger is the events htmx triggers on the client. +func (r *Response) HXTrigger() string { return r.Header.Get("HX-Trigger") } + +// HXRetarget is the CSS selector htmx swaps the response into instead. +func (r *Response) HXRetarget() string { return r.Header.Get("HX-Retarget") } + +// HXReplaceURL is the URL htmx puts into the address bar. +func (r *Response) HXReplaceURL() string { return r.Header.Get("HX-Replace-Url") } + +// HXRefresh reports whether htmx is told to reload the page. +func (r *Response) HXRefresh() bool { return r.Header.Get("HX-Refresh") == "true" } diff --git a/e2e/harness.go b/e2e/harness.go new file mode 100644 index 00000000..a1993f3e --- /dev/null +++ b/e2e/harness.go @@ -0,0 +1,391 @@ +// Package e2e runs the real web binary against a fresh database and drives it +// over HTTP. Tests only see what a browser would see (status codes, headers, +// HTML), so they keep passing while the code behind the routes is refactored. +// +// Every test package that uses Start needs a TestMain that calls Main: +// +// func TestMain(m *testing.M) { e2e.Main(m) } +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io/fs" + "maps" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/can3p/gogo/testcontainers/postgres" + "github.com/can3p/pcom/pkg/testutil/testdb" + "github.com/jmoiron/sqlx" +) + +var ( + // repoRoot is the module root, resolved from this file. + repoRoot = func() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Dir(filepath.Dir(file)) + }() + + // binPath is the web binary built by Main; empty when Main didn't build it. + binPath string +) + +// Main builds the web binary once for the test package, runs the tests and +// cleans up. With -short it builds nothing, and Start skips the test. +func Main(m *testing.M) { + flag.Parse() + + if testing.Short() { + os.Exit(m.Run()) + } + + dir, err := os.MkdirTemp("", "pcom-e2e-") + if err != nil { + fmt.Fprintln(os.Stderr, "e2e:", err) + os.Exit(1) + } + + binPath, err = buildBinary(dir) + if err != nil { + fmt.Fprintln(os.Stderr, "e2e:", err) + _ = os.RemoveAll(dir) + os.Exit(1) + } + + code := m.Run() + + _ = postgres.Cleanup() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +// buildBinary builds ./cmd/web with coverage instrumentation. An overlay adds +// a SIGTERM handler (testdata/exit_on_sigterm.go), so a stopped server exits +// normally and writes its coverage data to GOCOVERDIR. +func buildBinary(dir string) (string, error) { + const hookPkg = "github.com/can3p/pcom/pkg/types" + + list := exec.Command("go", "list", "./...") + list.Dir = repoRoot + + pkgs, err := list.Output() + if err != nil { + return "", fmt.Errorf("listing packages: %w", err) + } + + var coverPkgs []string + for _, p := range strings.Fields(string(pkgs)) { + if p != hookPkg { + coverPkgs = append(coverPkgs, p) + } + } + + overlay := map[string]map[string]string{"Replace": { + filepath.Join(repoRoot, "pkg", "types", "zz_e2e_exit_on_sigterm.go"): filepath.Join(repoRoot, "e2e", "testdata", "exit_on_sigterm.go"), + }} + + overlayJSON, err := json.Marshal(overlay) + if err != nil { + return "", err + } + + overlayPath := filepath.Join(dir, "overlay.json") + if err := os.WriteFile(overlayPath, overlayJSON, 0o600); err != nil { + return "", err + } + + bin := filepath.Join(dir, "web") + cmd := exec.Command("go", "build", "-cover", "-coverpkg", strings.Join(coverPkgs, ","), + "-overlay", overlayPath, "-o", bin, "./cmd/web") + cmd.Dir = repoRoot + + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("building ./cmd/web: %w\n%s", err, out) + } + + return bin, nil +} + +// App is one running instance of the web binary with its own database. +type App struct { + // URL is the server's root, such as http://127.0.0.1:41234, without a + // trailing slash. + URL string + // DB is the app's database, for creating fixtures with the factories and + // asserting on rows. + DB *sqlx.DB +} + +// Option configures Start. +type Option func(*config) + +type config struct { + env map[string]string +} + +// WithEnv sets an extra environment variable for the binary. +func WithEnv(key, value string) Option { + return func(c *config) { c.env[key] = value } +} + +// Start runs the web binary against a fresh database and returns once it +// serves GET / with 200. The process is stopped when the test ends, and its +// output is logged if the test failed. +func Start(t testing.TB, opts ...Option) *App { + t.Helper() + + if binPath == "" { + if testing.Short() { + t.Skip("e2e: skipped in -short mode") + } + + t.Fatal("e2e: the web binary was not built; call e2e.Main from TestMain") + } + + cfg := config{env: map[string]string{}} + for _, o := range opts { + o(&cfg) + } + + db := testdb.New(t) + work := workDir(t) + port := freePort(t) + url := fmt.Sprintf("http://127.0.0.1:%d", port) + + env := map[string]string{ + "PORT": fmt.Sprint(port), + "DATABASE_URL": db.URL, + "SESSION_SALT": "test", + "SITE_ROOT": url, + "GIN_MODE": "release", + } + if dir := coverDir(); dir != "" { + env["GOCOVERDIR"] = dir + } + maps.Copy(env, cfg.env) + + cmd := exec.Command(binPath) + cmd.Dir = work + cmd.Env = processEnv(env) + + out := &syncBuffer{} + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + t.Fatalf("e2e: starting the web binary: %v", err) + } + + exited := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(exited) + }() + + t.Cleanup(func() { + stop(cmd, exited) + + if t.Failed() { + t.Logf("e2e: web binary output:\n%s", out.String()) + } + }) + + waitReady(t, url, exited, out) + + return &App{URL: url, DB: db.DB} +} + +// coverDir is where the binary writes coverage data: the test's own +// -test.gocoverdir under `go test -cover` (which is what `make cover` +// passes), or else GOCOVERDIR from the environment. +func coverDir() string { + if f := flag.Lookup("test.gocoverdir"); f != nil && f.Value.String() != "" { + return f.Value.String() + } + + return os.Getenv("GOCOVERDIR") +} + +// processEnv is the test's environment with the given overrides. FLY_APP_NAME +// is removed, because it switches the binary to production mode. +func processEnv(overrides map[string]string) []string { + var env []string + + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + if _, ok := overrides[key]; ok || key == "FLY_APP_NAME" { + continue + } + + env = append(env, kv) + } + + for k, v := range overrides { + env = append(env, k+"="+v) + } + + return env +} + +// workDir makes the binary's working directory: templates and articles +// through a `client` symlink, and a stub `dist` whose manifest names every +// asset the templates ask for, because static_asset panics on unknown keys. +func workDir(t testing.TB) string { + t.Helper() + + work := t.TempDir() + + if err := os.Symlink(filepath.Join(repoRoot, "cmd", "web", "client"), filepath.Join(work, "client")); err != nil { + t.Fatal(err) + } + + keys, err := templateAssets(filepath.Join(repoRoot, "cmd", "web", "client", "html")) + if err != nil { + t.Fatal(err) + } + + manifest := map[string]string{} + + for _, key := range keys { + manifest[key] = key + + path := filepath.Join(work, "dist", filepath.FromSlash(key)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte("/* e2e stub */\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(work, "dist", "manifest.json"), data, 0o644); err != nil { + t.Fatal(err) + } + + return work +} + +var staticAssetRE = regexp.MustCompile(`static_asset\s+"([^"]+)"`) + +// templateAssets returns every key passed to static_asset in the templates. +func templateAssets(dir string) ([]string, error) { + seen := map[string]bool{} + var keys []string + + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + for _, m := range staticAssetRE.FindAllStringSubmatch(string(data), -1) { + if !seen[m[1]] { + seen[m[1]] = true + keys = append(keys, m[1]) + } + } + + return nil + }) + + if err == nil && len(keys) == 0 { + err = errors.New("no static_asset keys found in the templates") + } + + return keys, err +} + +func freePort(t testing.TB) int { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = l.Close() }() + + return l.Addr().(*net.TCPAddr).Port +} + +func waitReady(t testing.TB, url string, exited <-chan struct{}, out *syncBuffer) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url+"/", nil) + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + + select { + case <-exited: + t.Fatalf("e2e: the web binary exited during startup:\n%s", out.String()) + case <-ctx.Done(): + t.Fatalf("e2e: the web binary did not serve GET / with 200 within 30s (last error: %v):\n%s", err, out.String()) + case <-time.After(50 * time.Millisecond): + } + } +} + +// stop sends SIGTERM, so a coverage build writes its data, and kills the +// process if it hasn't exited after a few seconds. +func stop(cmd *exec.Cmd, exited <-chan struct{}) { + _ = cmd.Process.Signal(syscall.SIGTERM) + + select { + case <-exited: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-exited + } +} + +// syncBuffer collects the process output, written from exec's copying +// goroutines and read by the test. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} diff --git a/e2e/main_test.go b/e2e/main_test.go new file mode 100644 index 00000000..79938935 --- /dev/null +++ b/e2e/main_test.go @@ -0,0 +1,9 @@ +package e2e_test + +import ( + "testing" + + "github.com/can3p/pcom/e2e" +) + +func TestMain(m *testing.M) { e2e.Main(m) } diff --git a/e2e/smoke_test.go b/e2e/smoke_test.go new file mode 100644 index 00000000..166ad5cc --- /dev/null +++ b/e2e/smoke_test.go @@ -0,0 +1,32 @@ +package e2e_test + +import ( + "context" + "net/http" + "testing" + + "github.com/can3p/pcom/e2e" + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/stretchr/testify/require" +) + +func TestSmoke_AnonymousHome(t *testing.T) { + app := e2e.Start(t) + + resp := app.Client(t).Get("/").RequireStatus(http.StatusOK) + + require.NotZero(t, resp.Doc().Find("body").Length()) +} + +func TestSmoke_LoginReachesFeed(t *testing.T) { + app := e2e.Start(t) + user, err := factory.User(context.Background(), app.DB, factory.WithPassword("secret-pw")) + require.NoError(t, err) + + client := app.Client(t) + client.Get("/feed").RequireStatus(http.StatusFound) + + client.LoginAs(user.Email, "secret-pw") + + client.Get("/feed").RequireStatus(http.StatusOK) +} diff --git a/e2e/testdata/exit_on_sigterm.go b/e2e/testdata/exit_on_sigterm.go new file mode 100644 index 00000000..b0702550 --- /dev/null +++ b/e2e/testdata/exit_on_sigterm.go @@ -0,0 +1,26 @@ +// This file is added to pkg/types only in the E2E build, through +// `go build -overlay`. A binary built with -cover writes its coverage data +// when it exits normally; the web server has no signal handling, so without +// this a stopped server would lose its coverage. +// +// The cover tool ignores overlays on the packages it instruments, so the file +// goes into a package the E2E build leaves out of -coverpkg. pkg/types has no +// statements, so leaving it out costs no coverage, and cmd/web imports it. + +package types + +import ( + "os" + "os/signal" + "syscall" +) + +func init() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGTERM, os.Interrupt) + + go func() { + <-ch + os.Exit(0) + }() +} diff --git a/go.mod b/go.mod index 25d93848..44f68b2a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 + github.com/PuerkitoBio/goquery v1.13.0 github.com/alecthomas/assert/v2 v2.11.0 github.com/alecthomas/chroma v0.10.0 github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a @@ -48,6 +49,7 @@ require ( github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/repr v0.4.0 // indirect + github.com/andybalholm/cascadia v1.3.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect @@ -149,7 +151,7 @@ require ( golang.org/x/arch v0.29.0 // indirect golang.org/x/crypto v0.56.0 // indirect golang.org/x/image v0.41.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect diff --git a/go.sum b/go.sum index ba9d4801..3ac2df45 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugX github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM= +github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g= github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= 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= @@ -24,6 +26,8 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a h1:dIdcLbck6W67B5JFMewU5Dba1yKZA3MsT67i4No/zh0= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a/go.mod h1:Sdr/tmSOLEnncCuXS5TwZRxuk7deH1WXVY8cve3eVBM= @@ -410,8 +414,8 @@ golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= From 028211cd092f273cfc137d88e9cd33b86658c98e Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:21:23 +0200 Subject: [PATCH 07/14] W0.T0.5: testing guide for the new helpers Describe testdb, the factories, the fakes, golden files and the E2E harness as they now exist, with one worked example each of a unit, a DB and an E2E test. Drop the sections about the removed test container. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- docs/testing.md | 158 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 21 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 06f79015..5eacbc05 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,8 +1,6 @@ # Testing -The one document a test-writing subagent reads besides `AGENTS.md` and its own prompt. W0 (task T0.5) extends -it with the new helpers and one worked example each of a unit, a DB and an E2E test. W6 (task B0) adds a -"Browser tests" section with one worked example. +The one document a test-writing subagent reads besides `AGENTS.md` and its own prompt. The test layers, from cheapest to most expensive: @@ -22,10 +20,10 @@ The test layers, from cheapest to most expensive: - `_test.go` files, and `testdata/` directories. - New packages that production code does not import: `pkg/testutil/...`, `e2e/`, and `cmd/seed` (W4). - - The `testcontainers/postgres` package, `Makefile`, `.github/`, - `docker-compose.yml`, `.env.example`, `tools/` and `docs/`. The - migration and codegen scripts (`dbconfig.yml`, `sqlmigrate.sh`, - `generate.sh`, `sqlboiler.toml`) may change **in W4 only**. + - `Makefile`, `.github/`, `docker-compose.yml`, `.env.example`, `tools/` + and `docs/`. The migration and codegen scripts (`dbconfig.yml`, + `sqlmigrate.sh`, `generate.sh`, `sqlboiler.toml`) may change **in W4 + only**. - `go.mod`/`go.sum`, **only in W0** (test dependencies), **W4.S2** (the `tool` directives) and **W6.B0** (playwright-go). 2. **Bugs are filed, not fixed.** If a test exposes a bug, write the test for @@ -67,24 +65,142 @@ The test layers, from cheapest to most expensive: - **github.com/ovechkin-dm/mockio/v2** - Mock library for Go without code generation - **github.com/stretchr/testify** - Assertion and testing utilities (require, assert) -- **testcontainers/postgres** - PostgreSQL test container helper +- **github.com/can3p/gogo/testcontainers/postgres** - PostgreSQL test container helper, wrapped by `pkg/testutil/testdb` -## Test container (before W0) +## Database: pkg/testutil/testdb -Located in `testcontainers/postgres`: -- Provides `NewTestDB()` function that returns a `*TestDB` with a clean database instance -- Each test gets its own isolated database -- Migrations are automatically applied from `migrations` -- Container is shared across tests in a package for efficiency -- Container cleanup happens automatically after tests complete (with 5-minute expiration as fallback) -- Use `defer testDB.Close()` to clean up the database after each test +`db := testdb.New(t)` returns a fresh, fully migrated Postgres database, dropped when the test ends. `db.DB` +is a `*sqlx.DB` and also a `boil.ContextExecutor`, so it goes directly into sqlboiler and factory calls. +`db.URL` is its connection string, for handing to a subprocess (this is how `e2e.Start` gives the real binary +its own database). Every test gets its own database, so `t.Parallel()` is safe and encouraged. -## Factories (before W0) +## Fixtures: pkg/testutil/factory -Located in `pkg/feedops/testutil/factory.go`: -- Factory functions for creating test entities: `CreateUser`, `CreateRSSFeed`, `CreateRSSItem`, etc. -- Helper functions for retrieving entities: `GetRSSFeed`, `GetRSSItemsByFeed`, `GetUserFeedItemsByUser` -- All factory functions accept `context.Context` and `boil.ContextExecutor` for transaction support +Builders such as `factory.User(ctx, exec, opts...)`, `factory.Post(ctx, exec, authorID, opts...)`, +`factory.Comment(ctx, exec, postID, authorID, opts...)` and `factory.RSSFeed(ctx, exec, opts...)` insert one +row with sane defaults. Functional options override specific fields, e.g. `factory.WithPassword("secret")`, +`factory.Published()`, `factory.Visibility(core.PostVisibilityPublic)`. Readers, in `read.go`, fetch state +back through the same `exec`: `factory.GetUser`, `factory.GetPost`, `factory.ListPosts`, `factory.ListComments`, +`factory.ListOutgoingEmails`, `factory.ConnectionExists`. + +The package imports no `testing`, so it also works from e2e's subprocess-backed database. Tests reach the ORM +only through it (ground rule 3); a missing builder or reader is requested from the coordinator, not +improvised inline. `pkg/feedops/testutil` is legacy: don't use it in new tests. + +## Fakes and other helpers + +- **`testutil.Must(t, v, err)`** (`pkg/testutil`) - returns `v`, or fails the test immediately if `err != nil`; for one-line fixture setup. +- **`fakesender.New()`** (`pkg/testutil/fakesender`) - a `sender.Sender` that records every `Send` instead of delivering it. `Sent()` returns what was recorded; `FailWith(err)` makes `Send` fail instead (`nil` resumes recording). +- **`fakestorage.New()`** (`pkg/testutil/fakestorage`) - an in-memory `pkg/media/server.MediaStorage`. `FailUploadWith`, `FailDownloadWith` and `FailExistsWith(err)` inject an error into the matching call. +- **`ginctx.New(t, method, target, body, opts...)`** (`pkg/testutil/ginctx`) - a `*gin.Context` wired like a real request (cookie session under `"sess"`), plus its `*httptest.ResponseRecorder`. Options: `ginctx.WithUser(t, db, userID)`, `ginctx.WithCSPNonces(style, script)`. +- **`golden.Assert(t, name, got)`** (`pkg/testutil/golden`) - compares `got` against `testdata/.golden`. `UPDATE_GOLDEN=1 go test ./pkg/...` writes it instead of comparing; after regenerating, check `git diff --stat`, not the contents. + +## End-to-end: e2e + +Runs the real `cmd/web` binary against its own database and drives it over HTTP, so a test sees only what a +browser would (statuses, redirects, htmx headers, HTML). `-short` (`make test-short`) skips E2E entirely. +Every package that uses it needs `func TestMain(m *testing.M) { e2e.Main(m) }`. + +`e2e.Start(t, opts...)` returns `*App{URL, DB}`. `app.Client(t)` gives a cookie-carrying `*Client` with `Get`, +`PostForm`, `PostJSON`, `LoginAs(email, password)` and `Do(req)` for anything else; each returns a `*Response` +with `RequireStatus(code)`, `Doc()` (goquery), `Location()`, `HXRedirect()`, `HXTrigger()`, `HXRetarget()`, +`HXReplaceURL()` and `HXRefresh()`. A feed a test creates must point at an `httptest.Server` the test owns, +never a real remote URL. Mail is asserted through the outgoing queue for now: +`factory.ListOutgoingEmails(ctx, app.DB, core.OutgoingEmailWhere.EmailType.EQ(...))`. + +## Make targets + +`make test-short` runs everything except E2E. `make cover` runs unit, package and E2E tests together under one +`GOCOVERDIR` and prints a merged per-package coverage table. The quiet targets `check-q`, `test-q`, `vet-q` and +`cover-q` (see `AGENTS.md`) are for agents and narrow with `PKG=./pkg/links/...`. + +## Worked examples + +A unit test, pinning an unexported function's logic (ground rule 4): + +```go +package postops + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsURLMediaUpload(t *testing.T) { + t.Parallel() + + require.True(t, isURLMediaUpload("3fa85f64-5717-4562-b3fc-2c963f66afa6.png")) + require.False(t, isURLMediaUpload("not-a-uuid.png")) +} +``` + +A DB test, using `testdb`, `factory` and a reader alongside the function under test: + +```go +package userops_test + +import ( + "context" + "testing" + + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/can3p/pcom/pkg/testutil/testdb" + "github.com/can3p/pcom/pkg/userops" + "github.com/stretchr/testify/require" +) + +func TestGetDirectUserIDs(t *testing.T) { + t.Parallel() + + db := testdb.New(t).DB + ctx := context.Background() + + alice, err := factory.User(ctx, db) + require.NoError(t, err) + bob, err := factory.User(ctx, db) + require.NoError(t, err) + + _, _, err = factory.Connect(ctx, db, alice.ID, bob.ID) + require.NoError(t, err) + + ids, err := userops.GetDirectUserIDs(ctx, db, alice.ID) + require.NoError(t, err) + require.Contains(t, ids, bob.ID) + + got, err := factory.GetUser(ctx, db, bob.ID) + require.NoError(t, err) + require.Equal(t, bob.Email, got.Email) +} +``` + +An E2E test: + +```go +package e2e_test + +import ( + "context" + "net/http" + "testing" + + "github.com/can3p/pcom/e2e" + "github.com/can3p/pcom/pkg/testutil/factory" + "github.com/stretchr/testify/require" +) + +func TestFeedRequiresLogin(t *testing.T) { + app := e2e.Start(t) + user, err := factory.User(context.Background(), app.DB, factory.WithPassword("secret-pw")) + require.NoError(t, err) + + client := app.Client(t) + client.Get("/feed").RequireStatus(http.StatusFound) + + client.LoginAs(user.Email, "secret-pw") + client.Get("/feed").RequireStatus(http.StatusOK) +} +``` ## Mockio v2 From dd14f3123d2c0d51fa2fe3ce08c3c59ae433a61f Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:22:20 +0200 Subject: [PATCH 08/14] wave-run: tell subagents not to cat whole files W0's subagents printed whole files 33 times despite AGENTS.md's reading rules; the task preamble now says it in the task itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- .claude/skills/wave-run/task_prompt.py | 2 + docs/plan/w0.md | 167 ------------------------- 2 files changed, 2 insertions(+), 167 deletions(-) delete mode 100644 docs/plan/w0.md diff --git a/.claude/skills/wave-run/task_prompt.py b/.claude/skills/wave-run/task_prompt.py index 0f26824f..49add922 100755 --- a/.claude/skills/wave-run/task_prompt.py +++ b/.claude/skills/wave-run/task_prompt.py @@ -28,6 +28,7 @@ Do not change production code. Do not run git. Do not edit go.mod. Navigate with the LSP tool (load it with ToolSearch "select:LSP"); get model shapes with the model-shape skill (`make model T=`); never read pkg/model/core. On a failing test, follow the test-failure skill. +Never cat a whole file: grep -n or LSP documentSymbol first, then Read only the lines you need. Create fixtures with the test factories. If a helper is missing, stop and report exactly what you need rather than writing ORM calls in your test. If you find a bug: write the test for correct behavior, add t.Skip("known bug: "), and put a @@ -60,6 +61,7 @@ report it under needs:. Do not run git. Do not edit go.mod. Navigate with the LSP tool (load it with ToolSearch "select:LSP"); get model shapes with the model-shape skill (`make model T=`); never read pkg/model/core. On a failing test, follow the test-failure skill. +Never cat a whole file: grep -n or LSP documentSymbol first, then Read only the lines you need. Behavior must not change: the E2E tests (e2e/), the browser tests (e2e/browser) and the seed crawl are not edited. A test you move may change its call site but not its assertions. If an assertion has to change, stop and report it. diff --git a/docs/plan/w0.md b/docs/plan/w0.md deleted file mode 100644 index d8d5d50c..00000000 --- a/docs/plan/w0.md +++ /dev/null @@ -1,167 +0,0 @@ -## W0 — Test foundation - -**One session, strong model, sequential.** Everything else depends on the -contracts written here. Write them first, commit, then build. - -### T0.1 Test database - -Owns `testcontainers/postgres/` and `pkg/testutil/testdb/`. - -**Available in gogo:** the upgraded `testcontainers/postgres` was merged in -[can3p/gogo#5](https://github.com/can3p/gogo/pull/5) and released as -**`v0.0.2`** (`go get github.com/can3p/gogo@v0.0.2`). It provides: - -- testcontainers-go instead of dockertest; -- migrations applied once into a template database, with each test database - created by `CREATE DATABASE … TEMPLATE …`. There are 38 migrations today, - and W2/W3 will create hundreds of databases; -- a per-run migration bookkeeping table (`WithMigrationsTable`, default - `migrations`). pcom's copy today uses sql-migrate's Go default, - `gorp_migrations`, while `dbconfig.yml` and the CLI use `migrations`; -- `New(t, WithMigrationsDir(dir), …)`, which returns - `TestDB{DB *sqlx.DB, SQL *sql.DB, URL string}` and registers `t.Cleanup`, - plus `Cleanup()` for `TestMain`. The old `NewTestDB(Options)` still works, - but is deprecated. - -The task: - -- Bump gogo, **delete pcom's own `testcontainers/postgres`**, and point the - existing tests at gogo's package. -- Add `pkg/testutil/testdb.New(t testing.TB) *postgres.TestDB`, a thin - wrapper that resolves pcom's `migrations` directory relative to its own - source file and calls `postgres.New(t, postgres.WithMigrationsDir(dir))`. - The E2E harness hands `TestDB.URL` to the binary. -- Move the existing callers from `NewTestDB` to `testdb.New`: `pkg/feedops`, - `pkg/feedops/feeder` and `pkg/web`. That's a test-only change. - -### T0.2 Factories: `pkg/testutil/factory` - -**Must not import `testing`**, because `cmd/seed` uses it. Style: - -```go -func User(ctx context.Context, exec boil.ContextExecutor, opts ...UserOpt) (*core.User, error) -func WithPassword(pw string) UserOpt // hashes via pgsession.HashUserPwd, sets EmailConfirmedAt -func WithVisibility(v core.ProfileVisibility) UserOpt -``` - -Defaults must satisfy every constraint and be unique through an atomic counter: -`user7@example.test`, username `user7`, timezone `UTC`, confirmed. - -Required builders (one per table, plus the relationships the domain needs): - -- Users and graph: `User`, `Connect(a, b)` (both directions, as - `userops.CreateConnection` does), `Whitelist(who, allowsWho)`, - `MediationRequest(who, target, …)`, `MediatorDecision(req, mediator, decision)` -- Posts: `Post` (draft by default, with `Published()`, `Visibility(v)` and - `WithURL(u)` options), `Comment(post, author, …ReplyTo(c))`, `PostStat`, - `PostShare`, `PostPrompt(asker, recipient, …)`, `NormalizedURL` -- Account: `Invitation(user, …Sent(email))`, `SignupRequest`, `APIKey(user)`, - `UserStyle(user, css)`, `SetRegistrationOpen(bool)` -- Feeds: `RSSFeed`, `Subscription(user, feed)`, `RSSItem(feed, …)`, - `UserFeedItem(user, item, …)` -- Media and mail: `MediaUpload`, `OutgoingEmail` -- Readers: `GetUser`, `GetPost`, `ListPosts(userID)`, `ListComments(postID)`, - `ListOutgoingEmails(filter)`, `ConnectionExists(a, b)`, - `GetMediationRequest(id)` and so on. Add readers as tasks report the need; - keep them in `factory/read.go`. - -Write a self-test that builds every entity once. It proves the defaults -satisfy the schema. - -Leave `pkg/feedops/testutil` alone. Moving its callers over is optional -clean-up for W2.D8. - -### T0.3 Fakes and helpers: `pkg/testutil` - -- `fakesender`: implements `gogo/sender.Sender`. Records every `*sender.Mail` - with its `uniqueID` and `emailType`. `FailWith(err)` makes `Send` return an - error. Note that the code under test currently `log.Fatal`s on that error - (#112), so failure-path tests stay skipped until WB. For unit and package - tests this fake stays the right tool after R2. tommy (see R2) is for the E2E - and development paths, where real delivery is what is being tested. -- `fakestorage`: an in-memory `server.MediaStorage`, with injectable errors. -- `ginctx`: `New(t, method, target, body) (*gin.Context, *httptest.ResponseRecorder)`. - It installs a cookie session store under the name `sess`, as `main.go` does. - It has an option to set a logged-in user (`pgsession.SetUser` needs the - `*sqlx.DB`) and one to set CSP nonces. -- `golden`: `golden.Assert(t, name string, got []byte)` with `UPDATE_GOLDEN=1`. -- `testutil.Must[T](t, v T, err error) T`. - -### T0.4 End-to-end harness: `e2e/` - -A black-box harness that runs **the real binary**. That way W3's tests survive -R1 (router decomposition) and R2 (config) unchanged, and they are exactly what -proves those refactors safe. No production code changes are needed: - -- `TestMain` builds `./cmd/web` once, with `go build -cover -o $TMP/web`. -- `e2e.Start(t) *App` does the following: - - Creates a test database. - - Makes a temp working directory with a symlink `client` → `cmd/web/client`, - and a generated `dist/manifest.json`. The manifest maps every - `static_asset` key used by templates to a stub file (`main.css`, `main.js` - and the favicons; grep the templates). `static_asset` panics on unknown - keys, so the harness greps the key list from the templates instead of - hardcoding it. - - Picks a free port, and starts the binary with `PORT`, `DATABASE_URL`, - `SESSION_SALT=test`, `SITE_ROOT=http://127.0.0.1:`, and - `GOCOVERDIR` (when set). It does *not* set `FLY_APP_NAME`. - - Waits for `GET /` to return 200, kills the process on cleanup, and dumps - its stdout/stderr on failure. -- Note: the binary starts the email and RSS pollers. Until R2, emails go to - the console sender, so tests assert on the `outgoing_emails` queue. R2 adds - a tommy container to the harness and asserts on delivered mail through - tommy's API. The same container becomes the S3 target for uploads, and W6 - adds `WithRealAssets()` for the browser suite, so keep `Start` open to - options (`Start(t, opts ...Option)`). A feed - created in a test must point at an `httptest.Server` that the test owns, - never at the internet. -- `App.Client(t)` returns a cookie-jar client that does not follow redirects. - It offers `Get`, `PostForm`, `PostJSON(action, v)` and - `LoginAs(email, password)`. The CSRF token is scraped from - `` and sent as the `X-CSRFToken` - header. Helpers assert on htmx response headers (`HX-Redirect`, - `HX-Trigger`, `HX-Retarget`, `HX-Replace-Url`). -- HTML assertions use `github.com/PuerkitoBio/goquery` (new test-only - dependency). CSS selectors keep cheap-model tests robust. -- W0 ships a smoke test: an anonymous `GET /` returns 200, and a user created - by a factory can log in and reach `/feed`. -- CI needs no frontend build and no libvips for E2E beyond what is already - installed. - -### T0.5 Tooling and docs - -- `Makefile`: `test` writes `coverage.out`. CI already uploads that file to - Codecov, but nothing produces it today. `cover` runs unit and E2E tests, - merges the binary coverage (`go tool covdata textfmt`), and prints a - per-package table that excludes `pkg/model/core`. Add `test-short`, which - skips E2E via `testing.Short()`. -- Add `codecov.yml` ignoring `pkg/model/core/**`. -- Extend `docs/testing.md` (it already holds the ground rules): replace the - "before W0" sections with how to use each new helper, and add one worked - example each of a unit test, a DB test and an E2E test. Keep it short; - every test subagent reads it. Cheap models learn from the examples, so make - them exemplary. - -`make cover` does not exist before this task; W0 creates it, and later waves -rely on it for the full, merged per-package table (`make cover-q` stays the -per-task check). - -**Done when:** `make check` is green, the factory self-test and the E2E smoke -test pass in CI, and `make cover` prints the per-package table. - -### Delegation (token economy) - -The coordinator does T0.1 and T0.4 itself, because they define what everything -else builds on. The rest goes out once T0.1 is committed, in **one message**: - -| Piece | Model | Notes | -|---|---|---| -| T0.1 test database, gogo bump | coordinator | small; unblocks everything | -| T0.2 factories + self-test | `sonnet` | Needs only the schema (`make model T=`, `migrations/`). Owns `pkg/testutil/factory/`. | -| T0.3 fakes, `ginctx`, `golden`, `Must` | `sonnet` | Owns `pkg/testutil/{fakesender,fakestorage,ginctx,golden}/` and `pkg/testutil/must.go`. Signatures are given above; they are the contract. | -| T0.4 e2e harness | coordinator | Depends on T0.2's `User`/`WithPassword`, so it starts when T0.2 reports DONE. | -| T0.5 `Makefile` `test`/`cover`/`test-short`, `codecov.yml` | `haiku` | Mechanical. The quiet targets (`*-q`) already exist. | -| T0.5 `docs/testing.md` | `sonnet` | Last, once the helpers exist, so the worked examples are real code. | - -Coordinator verification is the budget in the `wave-run` skill: `make cover-q` -for the factory self-test, `git diff --stat`, one mutation check. From 017714586e145a7bb5ed25aff40a23cc105c90bd Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:22:20 +0200 Subject: [PATCH 09/14] docs: close W0 Mark W0 done and delete its wave file, record the wave (what was built, what turned out wrong, cost) in docs/archive/history.md, start docs/lessons.md, and add Q14 on E2E coverage in CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- docs/archive/history.md | 51 +++++++++++++++++++++++++++++++++++++ docs/implementation-plan.md | 6 ++--- docs/lessons.md | 17 +++++++++++++ docs/open-questions.md | 4 +++ docs/plan/r6.md | 2 -- 5 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 docs/archive/history.md create mode 100644 docs/lessons.md diff --git a/docs/archive/history.md b/docs/archive/history.md new file mode 100644 index 00000000..fa2956c3 --- /dev/null +++ b/docs/archive/history.md @@ -0,0 +1,51 @@ +# Modernization history + +Finished waves, newest last. Each entry records what was built, what turned +out wrong, what was left out on purpose, and what the wave cost in tokens. + +## W0 — Test foundation (2026-09-26, branch `test/w0-foundation`) + +**Built.** + +- `pkg/testutil/testdb.New(t)` over gogo `v0.0.2`'s `testcontainers/postgres` + (a template database, one copy per test). pcom's dockertest-based copy is deleted, and the + existing DB tests use the new helper. +- `pkg/testutil/factory`: a builder per table, the graph relationships, and + readers in `read.go`, with a self-test that builds every entity. It doesn't import + `testing`, so the seed command can use it. +- `pkg/testutil`: `Must`, `fakesender`, `fakestorage`, `ginctx`, `golden`. +- `e2e/`: builds `./cmd/web` once with `-cover`, runs it per test against + its own database with a stub asset manifest, and drives it with a + cookie-jar client that sends the scraped CSRF token and exposes htmx + headers. Two smoke tests (anonymous home, factory user logs in to `/feed`). +- `make test` writes `coverage.out`, `make test-short` skips E2E, `make cover` + merges unit and E2E binary coverage into one per-package table; + `codecov.yml`; `docs/testing.md` rewritten with worked examples. +- `task_prompt.py` now pastes a task's `###` section together with its table + row, and takes ownership from "Owns …". + +**Turned out wrong.** + +- The plan assumed the E2E binary could simply be killed. It has no signal + handling, and a `-cover` binary writes coverage only on a normal exit. A build + overlay adds a SIGTERM handler. It can't go into `cmd/web`, because the cover tool ignores + overlays on the packages it instruments, and it can't go into a dependency, because the module cache + can't be overlaid. So it goes into `pkg/types`, a package without statements, which is left out of + `-coverpkg`. +- `go test -cover` gives the test process its own `GOCOVERDIR`; the harness + passes the binary the `-test.gocoverdir` value instead. +- Small shape changes against the plan: `Client.PostJSON` takes a path, not + an action name; `ginctx.New` takes options (`WithUser`, `WithCSPNonces`) + after the four planned arguments. + +**Left out.** CI still runs `make test`, so Codecov doesn't see E2E coverage +(see Q14). `pkg/feedops/testutil` stays, for W2.D8. + +**Cost.** 5 sessions (coordinator plus 4 subagents: 2 sonnet builders, 1 haiku, 1 sonnet docs), +280 turns in total; the coordinator peaked at 148k context with 88k of tool +results, and the subagents at 43k–167k (the factories were the most expensive: +74 turns, 122k of results). 33 wasteful calls across all agents, nearly all +`cat` of whole files, plus 2 raw builds. Skills: wave-run and wave-close once +each. model-shape was used through `make model` (5 calls). LSP was loaded once +in total and test-failure never, because nothing failed. This is the first +recorded wave, so there is no earlier cost line to compare with. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index eaf11296..d5f535eb 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -2,7 +2,7 @@ Forward-looking only. This file is the **index**: status, ground rules and how waves are run. Each wave's tasks live in their own file, `docs/plan/.md` -(`w0.md`, `w1.md`, … `wb.md`, `r1.md`, …). **Read this file and the one wave file +(`w1.md`, … `wb.md`, `r1.md`, …). **Read this file and the one wave file you are working on; never the other wave files.** Running a wave (prompts, dispatch, verification) is the `wave-run` skill; @@ -44,7 +44,7 @@ Related documents: | Wave | Name | Depends on | State | Branch | |---|---|---|---|---| -| W0 | Test foundation | — (gogo `v0.0.2` released) | not started | `test/w0-foundation` | +| W0 | Test foundation | — (gogo `v0.0.2` released) | done | `test/w0-foundation` | | W1 | Unit tests, no database | W0 | not started | `test/w1-unit` | | W2 | Package tests against Postgres | W0 | not started | `test/w2-db` | | W3 | End-to-end HTTP tests | W0 | not started | `test/w3-e2e` | @@ -69,7 +69,7 @@ W0 ─────────┼── W3 (6 tasks) ──┼── W5 ── R3: after W5 R6: any time ``` -Each wave's tasks are in `docs/plan/.md` (lowercase: `w0.md`, `wb.md`, `r1.md`). +Each wave's tasks are in `docs/plan/.md` (lowercase: `w1.md`, `wb.md`, `r1.md`); a finished wave's file is deleted and its record moves to `docs/archive/history.md`. **After W0 lands, W1, W2, W3, W4 and W6 are independent of each other** and can run at the same time: about 40 tasks in total, each owning disjoint files. diff --git a/docs/lessons.md b/docs/lessons.md new file mode 100644 index 00000000..a193beb5 --- /dev/null +++ b/docs/lessons.md @@ -0,0 +1,17 @@ +# Lessons + +Generalizable lessons from running the waves. Wave-specific notes go in +`docs/archive/history.md`. + +- **Coverage from a subprocess.** A binary built with `go build -cover` writes + its data only when it exits normally (main returns, or `os.Exit`). Stop it + with a signal it handles, not a kill. Under `go test -cover`, pass the child the + test's `-test.gocoverdir` value: the test process's own `GOCOVERDIR` is + not the directory the caller asked for. +- **Build overlays and coverage.** `go build -overlay` files are ignored by + the cover tool for instrumented packages, and files under the module cache + can't be overlaid at all. To inject test-only code, overlay a package that is + excluded from `-coverpkg`. +- **Subagents cat files.** Even with AGENTS.md's reading rules, W0's + subagents printed whole files 33 times and loaded LSP once. The task + preamble now says it explicitly. diff --git a/docs/open-questions.md b/docs/open-questions.md index 5efda92a..683af1f1 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -24,6 +24,10 @@ Raised by the 2026-09-21 modernization survey. instead? That decouples templates and services from the ORM, at the cost of mapping code. Deciding before R5 matters, because bob changes the generated types anyway (see `docs/plan/r5.md`). +- **Q14. E2E coverage in CI.** CI runs `make test`, whose `coverage.out` + holds unit coverage only. Switching the CI step to `make cover` would send + the merged unit and E2E coverage to Codecov (`cmd/web` included) at the cost + of a slightly longer job. Switch now, or when W3 lands? ## Decided diff --git a/docs/plan/r6.md b/docs/plan/r6.md index 067424b1..63cf3b7c 100644 --- a/docs/plan/r6.md +++ b/docs/plan/r6.md @@ -3,8 +3,6 @@ - Drop `alecthomas/assert` for testify. - Drop both `pkg/errors` and `friendsofgo/errors` for stdlib `errors` and `fmt.Errorf("%w")`. -- `ory/dockertest` goes away with W0.T0.1, once pcom uses gogo's - testcontainers. - Check the unmaintained `antonlindstrom/pgstore` and `volatiletech/null` (the latter goes with bob). - Remove `plan.txt` (its content can go to GitHub issues or `docs/`) and From 90abb710f7c69ebee6c31ee993953fecfe423476 Mon Sep 17 00:00:00 2001 From: can3p <125821+can3p@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:24:36 +0000 Subject: [PATCH 10/14] chore: apply go fix --- e2e/harness.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/harness.go b/e2e/harness.go index a1993f3e..2dee420b 100644 --- a/e2e/harness.go +++ b/e2e/harness.go @@ -89,7 +89,7 @@ func buildBinary(dir string) (string, error) { } var coverPkgs []string - for _, p := range strings.Fields(string(pkgs)) { + for p := range strings.FieldsSeq(string(pkgs)) { if p != hookPkg { coverPkgs = append(coverPkgs, p) } From 10516e91429758cae0cf074f9fbc61455e1b3d5e Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 11:25:39 +0200 Subject: [PATCH 11/14] W0.T0.3: check Close in the fakestorage test (errcheck) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- pkg/testutil/fakestorage/fakestorage_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/testutil/fakestorage/fakestorage_test.go b/pkg/testutil/fakestorage/fakestorage_test.go index d8812991..b58bb71f 100644 --- a/pkg/testutil/fakestorage/fakestorage_test.go +++ b/pkg/testutil/fakestorage/fakestorage_test.go @@ -25,7 +25,7 @@ func TestStorage_RoundTrips(t *testing.T) { r, size, contentType, err := s.DownloadFile(ctx, "a.png") require.NoError(t, err) - defer r.Close() + defer func() { _ = r.Close() }() got, err := io.ReadAll(r) require.NoError(t, err) From b317ad75a9c6ee63e8419340fb79b2901eb602fc Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 12:46:37 +0200 Subject: [PATCH 12/14] ci: upload merged unit and E2E coverage (make cover) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97642ed3..940777f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libvips-dev - name: Run tests - run: make test + run: make cover - name: Upload coverage uses: codecov/codecov-action@v7 From a868484a0f6ed914c75bd15e62c4f54811606afb Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 12:46:37 +0200 Subject: [PATCH 13/14] docs: record Q14 as decided Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- docs/archive/history.md | 4 ++-- docs/open-questions.md | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/archive/history.md b/docs/archive/history.md index fa2956c3..6f959360 100644 --- a/docs/archive/history.md +++ b/docs/archive/history.md @@ -38,8 +38,8 @@ out wrong, what was left out on purpose, and what the wave cost in tokens. an action name; `ginctx.New` takes options (`WithUser`, `WithCSPNonces`) after the four planned arguments. -**Left out.** CI still runs `make test`, so Codecov doesn't see E2E coverage -(see Q14). `pkg/feedops/testutil` stays, for W2.D8. +**Left out.** `pkg/feedops/testutil` stays, for W2.D8. (CI switched from +`make test` to `make cover` before merge, so Codecov sees E2E coverage: Q14.) **Cost.** 5 sessions (coordinator plus 4 subagents: 2 sonnet builders, 1 haiku, 1 sonnet docs), 280 turns in total; the coordinator peaked at 148k context with 88k of tool diff --git a/docs/open-questions.md b/docs/open-questions.md index 683af1f1..582db385 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -24,11 +24,6 @@ Raised by the 2026-09-21 modernization survey. instead? That decouples templates and services from the ORM, at the cost of mapping code. Deciding before R5 matters, because bob changes the generated types anyway (see `docs/plan/r5.md`). -- **Q14. E2E coverage in CI.** CI runs `make test`, whose `coverage.out` - holds unit coverage only. Switching the CI step to `make cover` would send - the merged unit and E2E coverage to Codecov (`cmd/web` included) at the cost - of a slightly longer job. Switch now, or when W3 lands? - ## Decided - **2026-09-21. Q2, the API deletes any post:** fixed right away in PR #118, @@ -80,3 +75,5 @@ Raised by the 2026-09-21 modernization survey. - **2026-09-24. Browser tests:** playwright-go in `e2e/browser`, behind a build tag, reusing the E2E harness and the factories (W6). No pixel snapshots until browsers run in the tools container. +- **2026-09-26. Q14, E2E coverage in CI:** CI runs `make cover`, so Codecov + gets the merged unit and E2E coverage, `cmd/web` included. From 8d2e304d641ce516e35686e6c847fef65c835b20 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sat, 26 Sep 2026 13:03:09 +0200 Subject: [PATCH 14/14] e2e: stop imitating htmx in the HTTP client The server never reads HX-Request, and HTTP tests that pin HX-* response headers keep passing when an htmx upgrade breaks the pages. The client now sends plain requests and Response has no HX-* accessors: behavior that needs the page's JavaScript is tested in the browser suite. LoginAs checks that the session can open /feed instead of reading HX-Redirect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016UQyTX1ww97bDaWMEBtmuE --- docs/testing.md | 17 +++++++++++------ e2e/client.go | 46 ++++++++++++++++------------------------------ 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 5eacbc05..eda5919c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,8 +8,12 @@ The test layers, from cheapest to most expensive: |---|---|---|---| | Unit | next to the code | `make test-short` | pure functions, goldens | | Package (DB) | next to the code; after RS, the service tests | `make test` (Docker) | business rules and queries against a fresh database | -| E2E HTTP | `e2e/` | `make test` (Docker) | the real binary: statuses, redirects, htmx headers, HTML, resulting state. From R2, mail and S3 through tommy | -| Browser | `e2e/browser` (build tag `browser`) | `make test-ui` | the real binary with real assets in Chromium: JS, htmx swaps, dialogs, layout, no console or CSP errors | +| E2E HTTP | `e2e/` | `make test` (Docker) | server rules that don't depend on the frontend: access control and visibility, statuses, CSRF and auth guards, API, RSS, security headers, resulting state. From R2, mail and S3 through tommy | +| Browser | `e2e/browser` (build tag `browser`) | `make test-ui` | **everything a user does in a page**, in Chromium with the real assets: forms, buttons, htmx swaps and redirects, Stimulus controllers, dialogs, layout, no console or CSP errors | + +The split matters: an HTTP test that imitates htmx (sending its headers, pinning `HX-*` response headers) +keeps passing when an htmx upgrade breaks every page. So a behavior that needs the page's JavaScript to +happen is tested in the browser, never over plain HTTP. ## Ground rules for W0–W6 @@ -97,14 +101,15 @@ improvised inline. `pkg/feedops/testutil` is legacy: don't use it in new tests. ## End-to-end: e2e -Runs the real `cmd/web` binary against its own database and drives it over HTTP, so a test sees only what a -browser would (statuses, redirects, htmx headers, HTML). `-short` (`make test-short`) skips E2E entirely. +Runs the real `cmd/web` binary against its own database and drives it with plain HTTP: statuses, redirects, +headers, HTML and the resulting database state. It is for server rules that don't depend on the frontend +(see the layers table); the client does not imitate htmx, and user flows belong in the browser suite. +`-short` (`make test-short`) skips E2E entirely. Every package that uses it needs `func TestMain(m *testing.M) { e2e.Main(m) }`. `e2e.Start(t, opts...)` returns `*App{URL, DB}`. `app.Client(t)` gives a cookie-carrying `*Client` with `Get`, `PostForm`, `PostJSON`, `LoginAs(email, password)` and `Do(req)` for anything else; each returns a `*Response` -with `RequireStatus(code)`, `Doc()` (goquery), `Location()`, `HXRedirect()`, `HXTrigger()`, `HXRetarget()`, -`HXReplaceURL()` and `HXRefresh()`. A feed a test creates must point at an `httptest.Server` the test owns, +with `RequireStatus(code)`, `Doc()` (goquery) and `Location()`; `Header` and `Body` are plain fields. A feed a test creates must point at an `httptest.Server` the test owns, never a real remote URL. Mail is asserted through the outgoing queue for now: `factory.ListOutgoingEmails(ctx, app.DB, core.OutgoingEmailWhere.EmailType.EQ(...))`. diff --git a/e2e/client.go b/e2e/client.go index 7789cea3..1ba4f374 100644 --- a/e2e/client.go +++ b/e2e/client.go @@ -13,9 +13,15 @@ import ( "github.com/PuerkitoBio/goquery" ) -// Client is a browser-like HTTP client for one App: it keeps cookies, does -// not follow redirects (tests assert on them), and sends the CSRF token that -// pages carry in , as htmx does. +// Client is a plain HTTP client for one App, for testing server rules that +// don't depend on the frontend: access control, status codes, the API, RSS, +// security headers. It keeps cookies, does not follow redirects (tests assert +// on them), and sends the CSRF token the pages carry. +// +// It deliberately does not imitate htmx: no HX-Request header, and Response +// has no accessors for HX-* response headers. Anything that depends on htmx +// or the page's JavaScript is tested in a real browser (e2e/browser), where +// an htmx upgrade that breaks the page fails the test. type Client struct { t testing.TB app *App @@ -57,7 +63,7 @@ func (c *Client) Get(path string) *Response { return c.Do(req) } -// PostForm posts url-encoded form values, as a plain htmx form does. +// PostForm posts url-encoded form values. func (c *Client) PostForm(path string, form url.Values) *Response { c.t.Helper() @@ -71,8 +77,7 @@ func (c *Client) PostForm(path string, form url.Values) *Response { return c.post(req) } -// PostJSON posts v as JSON, as htmx's json-enc extension does for the -// action controller: c.PostJSON("/controls/action/", payload). +// PostJSON posts v as JSON, the body the /controls/action endpoints accept. func (c *Client) PostJSON(path string, v any) *Response { c.t.Helper() @@ -91,28 +96,24 @@ func (c *Client) PostJSON(path string, v any) *Response { return c.post(req) } -// LoginAs logs in through the login form and fails the test if the login is -// rejected. +// LoginAs logs in by posting the login form, and fails the test unless the +// session can then open /feed. func (c *Client) LoginAs(email, password string) { c.t.Helper() c.Get("/login").RequireStatus(http.StatusOK) resp := c.PostForm("/form/login", url.Values{"email": {email}, "password": {password}}) - resp.RequireStatus(http.StatusOK) - if resp.HXRedirect() == "" { - c.t.Fatalf("e2e: login as %s was rejected:\n%s", email, resp.Body) + if feed := c.Get("/feed"); feed.StatusCode != http.StatusOK { + c.t.Fatalf("e2e: login as %s was rejected (status %d):\n%s", email, resp.StatusCode, resp.Body) } } -// Do sends req as htmx would: with the HX-Request header and, on anything -// but GET, the CSRF token. +// Do sends req, adding the CSRF token to anything but a GET. func (c *Client) Do(req *http.Request) *Response { c.t.Helper() - req.Header.Set("HX-Request", "true") - if req.Method != http.MethodGet && c.csrf != "" { req.Header.Set("X-CSRFToken", c.csrf) } @@ -200,18 +201,3 @@ func (r *Response) Doc() *goquery.Document { // Location is the redirect target of a 3xx response. func (r *Response) Location() string { return r.Header.Get("Location") } - -// HXRedirect is the htmx client-side redirect target. -func (r *Response) HXRedirect() string { return r.Header.Get("HX-Redirect") } - -// HXTrigger is the events htmx triggers on the client. -func (r *Response) HXTrigger() string { return r.Header.Get("HX-Trigger") } - -// HXRetarget is the CSS selector htmx swaps the response into instead. -func (r *Response) HXRetarget() string { return r.Header.Get("HX-Retarget") } - -// HXReplaceURL is the URL htmx puts into the address bar. -func (r *Response) HXReplaceURL() string { return r.Header.Get("HX-Replace-Url") } - -// HXRefresh reports whether htmx is told to reload the page. -func (r *Response) HXRefresh() bool { return r.Header.Get("HX-Refresh") == "true" }