diff --git a/.claude/skills/wave-run/task_prompt.py b/.claude/skills/wave-run/task_prompt.py index 253c0049..49add922 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. """ @@ -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. @@ -119,9 +121,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 +133,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 +153,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]) 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 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/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/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/**" diff --git a/docs/archive/history.md b/docs/archive/history.md new file mode 100644 index 00000000..6f959360 --- /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.** `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 +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..582db385 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -24,7 +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`). - ## Decided - **2026-09-21. Q2, the API deletes any post:** fixed right away in PR #118, @@ -76,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. 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 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. diff --git a/docs/testing.md b/docs/testing.md index 06f79015..eda5919c 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: @@ -10,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 @@ -22,10 +24,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 +69,143 @@ 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` + +## Database: pkg/testutil/testdb + +`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. + +## Fixtures: pkg/testutil/factory + +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 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) 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(...))`. + +## 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" -## Test container (before W0) + "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" +) -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 +func TestGetDirectUserIDs(t *testing.T) { + t.Parallel() -## Factories (before W0) + db := testdb.New(t).DB + ctx := context.Background() -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 + 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 diff --git a/e2e/client.go b/e2e/client.go new file mode 100644 index 00000000..1ba4f374 --- /dev/null +++ b/e2e/client.go @@ -0,0 +1,203 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + + "github.com/PuerkitoBio/goquery" +) + +// 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 + 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. +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, the body the /controls/action endpoints accept. +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 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}}) + + 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, adding the CSRF token to anything but a GET. +func (c *Client) Do(req *http.Request) *Response { + c.t.Helper() + + 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") } diff --git a/e2e/harness.go b/e2e/harness.go new file mode 100644 index 00000000..2dee420b --- /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.FieldsSeq(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 69b553e6..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 @@ -28,10 +29,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,8 +48,8 @@ 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/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 @@ -70,68 +69,91 @@ 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 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 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..3ac2df45 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,9 +16,9 @@ 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/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= @@ -81,35 +85,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 +148,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 +166,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 +173,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 +196,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 +213,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 +232,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 +243,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 +256,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 +282,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 +297,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 +322,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 +335,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 +349,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 +373,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 +405,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/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= -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 +458,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/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 +} 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..b58bb71f --- /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 func() { _ = 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() +} 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) - }) -}