test: add self-contained integration environment

Separate schema migrations from environment-specific fixtures, run the full API workflow against a disposable PostgreSQL Testcontainer, and provide seeded development and UI test environments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 21:11:20 +02:00
parent 6153017b62
commit 6969f79015
18 changed files with 772 additions and 269 deletions

View file

@ -5,97 +5,72 @@
Run repository-wide commands from the root: Run repository-wide commands from the root:
```bash ```bash
make dev # build and run PostgreSQL, API, and frontend make dev # run PostgreSQL, API, and frontend with development fixtures
make db-up # start only PostgreSQL make test # complete suite, including disposable PostgreSQL integration tests
make migrate # apply Goose migrations make test-integration # Go tests; starts its own PostgreSQL Testcontainer
make migrate-down # roll back one migration make test-env # isolated seeded UI stack at http://localhost:15173
make test # backend and frontend unit/component tests make test-env-down # remove the test stack and its PostgreSQL volume
make test-integration # PostgreSQL-backed repository tests; database must be running
make lint # go vet and ESLint make lint # go vet and ESLint
make build # compile the API and production frontend make build # compile the API and production frontend
``` ```
Run focused tests from the owning application: Focused tests:
```bash ```bash
cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$' cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$'
cd backend && go test -tags=integration ./internal/integration -run '^TestCompleteInterviewWorkflow$'
cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title"
``` ```
The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Frontend The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
formatting can be checked with `cd frontend && npm run format:check`; backend files `cd frontend && npm run format:check` for frontend formatting and `gofmt` for Go.
are formatted with `gofmt`.
## Architecture ## Architecture
The repository has two applications: - `backend/cmd/api` wires configuration, PostgreSQL, security, services, handlers,
and the Chi router. Requests flow through `internal/web/{router,handler,model,mapper}`
to `internal/service/{model,services}` and then
`internal/database/{repository,mapper,entity}`. Never expose GORM entities from
handlers.
- `backend/cmd/seed` applies embedded environment-aware fixtures.
- `frontend/src/App.tsx` owns routing/layout/screens, `src/api.ts` is the typed HTTP
boundary, and `src/types.ts` contains client domain types. Vite proxies `/api` to
the Go service in development.
- `backend/` is a Go HTTP API. `cmd/api` wires configuration, PostgreSQL, security, Goose files in `backend/migrations` are schema-only and run in every environment.
services, handlers, and routing. Requests flow through `internal/database/seed/common.sql` contains shared reference data;
`internal/web/{router,handler,model,mapper}` to `development.sql` and `test.sql` contain idempotent environment-specific fixtures.
`internal/service/{model,services}` and then Every entity change requires a migration plus corresponding fixture and integration
`internal/database/{repository,mapper,entity}`. Do not bypass these mapping test updates. Never put demo users or test records in schema migrations.
boundaries by exposing GORM entities from handlers.
- `frontend/` is a React SPA. `src/App.tsx` owns routing/layout/screens,
`src/api.ts` is the typed HTTP boundary, and `src/types.ts` contains client domain
types. Vite proxies `/api` to the Go service in development.
Goose SQL files in `backend/migrations/` are the database schema source of truth. Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
The initial migration also supplies representative jobs, questions, participants, protected except `/api/health`, `/api/auth/login`, and opaque-token participant
traits, and the local interviewer account. interview routes. Never expose password hashes, CV/photo bytes, or interviewer
reference answers in public JSON.
Authentication is custom bearer-token authentication: passwords are bcrypt hashes, Questions are permanent reusable records linked to jobs through
`POST /api/auth/login` issues an HMAC-signed JWT, and all domain routes require that `job_posting_questions`; detach links rather than deleting questions. Deprecated
token. Never return password hashes or participant photo/CV bytes in JSON; those questions remain searchable but cannot be newly attached. Question values are
files have dedicated endpoints. `open`, `multiple_choice`, `radio`, and `code_review`. Choice types require at least
two options. Open/code-review types require `referenceAnswer`; code review also
requires `codeSnippet`.
CV ingestion stores the original bytes and extracts text from plain text, PDF, or Jobs move through `draft`, `published`, and `deprecated`. Publishing requires a
DOCX input. Trait extraction only considers normalized traits already present in question, only published jobs can create invitations, and candidate matching uses
the database. Candidate matching compares a participant's extracted traits with a labels plus both skill groups with a 60% threshold. Invitation URLs use
job's labels, required skills, and additional skills; include matches only at the `/participant/:invitationId`, where the identifier is an opaque token. Participant
`service.CandidateThreshold` of 60% or higher. questions are acceptance-gated and answers are saved before final submission;
interviewer review uses
Questions are permanent, reusable library records and jobs own many-to-many links `GET /api/jobs/{id}/invitations/{invitationId}`.
to them. Detaching removes only the link. The lifecycle is
`draft -> published -> deprecated`; publishing requires at least one question, and
only published jobs can create invitations. Once an invitation exists, its question
set cannot be changed, and its duration is snapshotted so later job edits do not
alter an active interview.
Invitation URLs expose an opaque UUID token as `/participant/:invitationId` and use
public `/api/interviews/{token}` API routes; they do not use interviewer JWT
authentication. An invitation progresses from
`pending` to `accepted` to `completed`. Answers may only be saved while accepted
and before the snapshotted timer expires. The participant UI must gate questions
behind explicit acceptance and provide First, Previous, Next, Last, and Submit
controls. Interviewer review uses the protected
`GET /api/jobs/{id}/invitations/{invitationId}` endpoint, which intentionally
includes reference answers; the public interview endpoint does not.
## Repository conventions ## Repository conventions
- Every Go interface name starts with `I` (`IStore`, `IJobService`, - Prefix every Go interface name with `I`.
`IParticipantService`, `ITokenManager`). - Keep web, service, and persistence models separate and convert them in adjacent
- Keep transport, service, and persistence structs separate. Conversion belongs in `mapper` packages.
the adjacent `mapper` package, not in handlers or GORM entities. - Participant creation uses multipart fields `firstName`, `lastName`, `birthday`,
- Question types are the exact API values `open`, `multiple_choice`, `radio`, and `email`, `contactInfo`, `photo`, and `cv`.
`code_review`. Multiple-choice and radio questions require at least two options. - Return explicit errors; map expected failures to HTTP status codes and log
- Never delete question records through job operations. Use unexpected failures instead of silently falling back.
`job_posting_questions` to attach/detach, and use the `deprecated` flag to retire - Commit every repository change with a meaningful conventional prefix such as
library questions. Full question edits use `PUT /api/questions/{id}`; deprecation `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.
remains a separate `PATCH`.
- The question create/edit form builds multiple-choice and radio options one at a
time. Keep option editing state separate from the saved options array and require
at least two options for those types. Open and code-review questions instead
require a `referenceAnswer` textarea. Never include that interviewer-only answer
in public interview responses.
- Code-review questions also require `codeSnippet`. It is safe to include in the
participant payload and is rendered as a line-numbered pull-request review panel;
the participant's review comment remains the ordinary persisted answer.
- Traits are de-duplicated case-insensitively through their normalized database
value. Labels and both skill groups all reuse the same `traits` table.
- Participant creation is multipart form data using `firstName`, `lastName`,
`birthday`, `email`, `contactInfo`, `photo`, and `cv`.
- Return explicit errors. Handlers convert expected validation/not-found/authentication
failures to HTTP responses and log unexpected failures rather than silently
falling back to demo data.

26
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- uses: actions/setup-node@v5
with:
node-version: 26
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: make test
- run: make lint
- run: make build

View file

@ -4,7 +4,7 @@ DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=dis
JWT_SECRET ?= local-development-secret-change-me-12345 JWT_SECRET ?= local-development-secret-change-me-12345
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0 GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
.PHONY: dev db-up db-down migrate migrate-down backend frontend test test-backend test-frontend test-integration lint build .PHONY: dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-env test-env-down lint build
dev: dev:
docker compose up --build docker compose up --build
@ -21,13 +21,19 @@ migrate:
migrate-down: migrate-down:
$(GOOSE) -dir migrations postgres "$(DATABASE_URL)" down $(GOOSE) -dir migrations postgres "$(DATABASE_URL)" down
seed:
cd backend && DATABASE_URL="$(DATABASE_URL)" APP_ENV="$${APP_ENV:-development}" go run ./cmd/seed
backend: backend:
cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api
frontend: frontend:
cd frontend && npm run dev cd frontend && npm run dev
test: test-backend test-frontend frontend-install:
cd frontend && npm ci
test: frontend-install test-integration test-frontend
test-backend: test-backend:
cd backend && go test ./... cd backend && go test ./...
@ -36,7 +42,14 @@ test-frontend:
cd frontend && npm test cd frontend && npm test
test-integration: test-integration:
cd backend && TEST_DATABASE_URL="$(DATABASE_URL)" go test -tags=integration ./internal/database/repository cd backend && go test -tags=integration ./...
test-env:
COMPOSE_PROJECT_NAME=go-guess-test APP_ENV=test DB_PORT=15432 API_PORT=18080 \
WEB_PORT=15173 FRONTEND_URL=http://localhost:15173 docker compose up -d --build
test-env-down:
COMPOSE_PROJECT_NAME=go-guess-test docker compose down -v --remove-orphans
lint: lint:
cd backend && go vet ./... cd backend && go vet ./...

View file

@ -29,6 +29,7 @@ To run the applications outside containers:
```bash ```bash
make db-up make db-up
make migrate make migrate
make seed
make backend make backend
make frontend make frontend
``` ```
@ -39,8 +40,8 @@ outside local development.
## Quality commands ## Quality commands
```bash ```bash
make test make test # full suite, including disposable PostgreSQL integration tests
make test-integration make test-integration # Go integration suite with its own PostgreSQL container
make lint make lint
make build make build
``` ```
@ -57,6 +58,19 @@ Run one frontend test:
cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title"
``` ```
Docker must be available for `make test`. The integration suite starts PostgreSQL
18 in a disposable Testcontainer, applies every Goose migration, loads deterministic
test fixtures, exercises login and the complete interview workflow, and removes the
container afterward.
Start an isolated application stack populated with test data for manual UI testing:
```bash
make test-env
# Open http://localhost:15173 and use integration@example.com / integration-password
make test-env-down
```
## Architecture ## Architecture
Requests enter the Chi router and web handlers under `backend/internal/web`. Requests enter the Chi router and web handlers under `backend/internal/web`.
@ -72,9 +86,12 @@ submission without requiring an interviewer login. Interviewers can review every
saved answer from the job's invitation list. Vite proxies `/api` to the Go service saved answer from the job's invitation list. Vite proxies `/api` to the Go service
during local development. during local development.
Goose migrations in `backend/migrations` are the schema source of truth and include Goose migrations in `backend/migrations` are schema-only and run in every
representative local data. Uploaded CVs are stored as bytes; plain text, PDF, and environment. Embedded SQL under `backend/internal/database/seed` separates shared
DOCX content is inspected for normalized traits already defined by job postings. reference traits from idempotent development and test fixtures. Docker Compose
loads development fixtures by default; production loads only shared data. Uploaded
CVs are stored as bytes; plain text, PDF, and DOCX content is inspected for
normalized traits already defined by job postings.
Jobs move through `draft`, `published`, and `deprecated` states. Questions are Jobs move through `draft`, `published`, and `deprecated` states. Questions are
editable, permanent library records: jobs attach and detach links rather than editable, permanent library records: jobs attach and detach links rather than

View file

@ -3,7 +3,8 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed
FROM golang:1.27.1-alpine AS tools FROM golang:1.27.1-alpine AS tools
RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
@ -12,8 +13,9 @@ FROM alpine:3.22
RUN addgroup -S app && adduser -S app -G app RUN addgroup -S app && adduser -S app -G app
WORKDIR /app WORKDIR /app
COPY --from=build /out/api /usr/local/bin/api COPY --from=build /out/api /usr/local/bin/api
COPY --from=build /out/seed /usr/local/bin/seed
COPY --from=tools /out/goose /usr/local/bin/goose COPY --from=tools /out/goose /usr/local/bin/goose
COPY migrations ./migrations COPY migrations ./migrations
USER app USER app
EXPOSE 8080 EXPOSE 8080
CMD ["sh", "-c", "goose -dir migrations postgres \"$DATABASE_URL\" up && exec api"] CMD ["sh", "-c", "goose -dir migrations postgres \"$DATABASE_URL\" up && seed && exec api"]

33
backend/cmd/seed/main.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"context"
"log/slog"
"os"
"github.com/danyel/go-guess/backend/internal/database"
"github.com/danyel/go-guess/backend/internal/database/seed"
)
func main() {
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
slog.Error("DATABASE_URL is required")
os.Exit(1)
}
db, err := database.Open(databaseURL)
if err != nil {
slog.Error("connect database", "error", err)
os.Exit(1)
}
sqlDB, err := db.DB()
if err != nil {
slog.Error("get database connection", "error", err)
os.Exit(1)
}
defer sqlDB.Close()
if err := seed.Apply(context.Background(), sqlDB, os.Getenv("APP_ENV")); err != nil {
slog.Error("seed database", "error", err)
os.Exit(1)
}
}

View file

@ -6,20 +6,73 @@ require (
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.5 github.com/jackc/pgx/v5 v5.9.2
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.54.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0 gorm.io/gorm v1.31.0
) )
require ( require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/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/felixge/httpsnoop v1.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/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/stretchr/testify v1.11.0 // indirect github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/sync v0.17.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
golang.org/x/text v0.30.0 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mfridman/interpolate v0.0.2 // 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/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pressly/goose/v3 v3.26.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // 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/yusufpapurcu/wmi v1.2.4 // 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.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View file

@ -1,8 +1,46 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/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/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -13,27 +51,105 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU= github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
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/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.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/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -0,0 +1,9 @@
INSERT INTO traits (name, normalized) VALUES
('Go', 'go'),
('PostgreSQL', 'postgresql'),
('Docker', 'docker'),
('React', 'react'),
('TypeScript', 'typescript'),
('Kubernetes', 'kubernetes'),
('Backend', 'backend')
ON CONFLICT (normalized) DO NOTHING;

View file

@ -0,0 +1,87 @@
INSERT INTO users (email, password_hash, role)
VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer')
ON CONFLICT (email) DO NOTHING;
INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.',
'Senior', 'Backend Engineer', 'published', 60
WHERE NOT EXISTS (SELECT 1 FROM job_postings WHERE title = 'Senior Go Backend Engineer');
INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Full-stack Product Engineer', 'Deliver accessible operational tools from database to browser.',
'Medior', 'Full-stack Engineer', 'published', 45
WHERE NOT EXISTS (SELECT 1 FROM job_postings WHERE title = 'Full-stack Product Engineer');
INSERT INTO questions (text, type, reference_answer)
SELECT 'How do you design graceful shutdown for an HTTP service?', 'open',
'Stop accepting work, cancel dependent operations, drain active requests, and close resources within a deadline.'
WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'How do you design graceful shutdown for an HTTP service?');
INSERT INTO questions (text, type)
SELECT 'Which construct protects shared data across goroutines?', 'radio'
WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Which construct protects shared data across goroutines?');
INSERT INTO questions (text, type, reference_answer, code_snippet)
SELECT 'Review this component and identify unnecessary renders.', 'code_review',
'Use a stable key and avoid recreating expensive derived values when the candidates input has not changed.',
E'function CandidateList({ candidates }) {\n return candidates.map((candidate, index) => (\n <CandidateCard key={index} candidate={candidate} />\n ));\n}'
WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Review this component and identify unnecessary renders.');
INSERT INTO question_options (question_id, text)
SELECT q.id, option.text
FROM questions q
CROSS JOIN (VALUES ('sync.Mutex'), ('context.Context'), ('http.Client')) AS option(text)
WHERE q.text = 'Which construct protects shared data across goroutines?'
AND NOT EXISTS (SELECT 1 FROM question_options existing WHERE existing.question_id = q.id);
INSERT INTO job_posting_questions (job_posting_id, question_id)
SELECT j.id, q.id
FROM job_postings j
JOIN questions q ON q.text IN (
'How do you design graceful shutdown for an HTTP service?',
'Which construct protects shared data across goroutines?'
)
WHERE j.title = 'Senior Go Backend Engineer'
ON CONFLICT DO NOTHING;
INSERT INTO job_posting_questions (job_posting_id, question_id)
SELECT j.id, q.id
FROM job_postings j
JOIN questions q ON q.text = 'Review this component and identify unnecessary renders.'
WHERE j.title = 'Full-stack Product Engineer'
ON CONFLICT DO NOTHING;
INSERT INTO job_posting_labels (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('backend', 'go')
WHERE j.title = 'Senior Go Backend Engineer' ON CONFLICT DO NOTHING;
INSERT INTO job_required_skills (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('go', 'postgresql', 'docker')
WHERE j.title = 'Senior Go Backend Engineer' ON CONFLICT DO NOTHING;
INSERT INTO job_additional_skills (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized = 'kubernetes'
WHERE j.title = 'Senior Go Backend Engineer' ON CONFLICT DO NOTHING;
INSERT INTO job_posting_labels (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('react', 'typescript')
WHERE j.title = 'Full-stack Product Engineer' ON CONFLICT DO NOTHING;
INSERT INTO job_required_skills (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('go', 'react', 'typescript')
WHERE j.title = 'Full-stack Product Engineer' ON CONFLICT DO NOTHING;
INSERT INTO job_additional_skills (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized = 'docker'
WHERE j.title = 'Full-stack Product Engineer' ON CONFLICT DO NOTHING;
INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename)
VALUES
('Ada', 'Lovelace', '1990-12-10', 'ada@example.com', '+32 470 00 00 01',
convert_to('Senior backend engineer with Go, PostgreSQL and Docker experience.', 'UTF8'), 'ada-cv.txt'),
('Grace', 'Hopper', '1988-12-09', 'grace@example.com', '+32 470 00 00 02',
convert_to('Full-stack developer working with Go, React and TypeScript.', 'UTF8'), 'grace-cv.txt')
ON CONFLICT (email) DO NOTHING;
INSERT INTO participant_traits (participant_id, trait_id)
SELECT p.id, t.id FROM participants p JOIN traits t ON t.normalized IN ('go', 'postgresql', 'docker')
WHERE p.email = 'ada@example.com' ON CONFLICT DO NOTHING;
INSERT INTO participant_traits (participant_id, trait_id)
SELECT p.id, t.id FROM participants p JOIN traits t ON t.normalized IN ('go', 'react', 'typescript')
WHERE p.email = 'grace@example.com' ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,38 @@
package seed
import (
"context"
"database/sql"
"embed"
"fmt"
)
//go:embed *.sql
var files embed.FS
func Apply(ctx context.Context, db *sql.DB, environment string) error {
if _, err := apply(ctx, db, "common.sql"); err != nil {
return err
}
switch environment {
case "", "production":
return nil
case "development", "test":
_, err := apply(ctx, db, environment+".sql")
return err
default:
return fmt.Errorf("unsupported APP_ENV %q", environment)
}
}
func apply(ctx context.Context, db *sql.DB, filename string) (sql.Result, error) {
content, err := files.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read seed %s: %w", filename, err)
}
result, err := db.ExecContext(ctx, string(content))
if err != nil {
return nil, fmt.Errorf("apply seed %s: %w", filename, err)
}
return result, nil
}

View file

@ -0,0 +1,40 @@
INSERT INTO users (email, password_hash, role)
VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'interviewer')
ON CONFLICT (email) DO NOTHING;
INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Integration Backend Engineer', 'Deterministic job fixture for API and UI integration tests.',
'Senior', 'Backend Engineer', 'published', 30
WHERE NOT EXISTS (SELECT 1 FROM job_postings WHERE title = 'Integration Backend Engineer');
INSERT INTO questions (text, type, reference_answer)
SELECT 'Explain graceful shutdown.', 'open',
'Stop new work, cancel dependencies, drain requests, and close resources.'
WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Explain graceful shutdown.');
INSERT INTO questions (text, type, reference_answer, code_snippet)
SELECT 'Review the error handling.', 'code_review', 'Return or log the error instead of discarding it.',
E'func save() error {\n persist()\n return nil\n}'
WHERE NOT EXISTS (SELECT 1 FROM questions WHERE text = 'Review the error handling.');
INSERT INTO job_posting_questions (job_posting_id, question_id)
SELECT j.id, q.id FROM job_postings j
JOIN questions q ON q.text IN ('Explain graceful shutdown.', 'Review the error handling.')
WHERE j.title = 'Integration Backend Engineer'
ON CONFLICT DO NOTHING;
INSERT INTO job_posting_labels (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized = 'backend'
WHERE j.title = 'Integration Backend Engineer' ON CONFLICT DO NOTHING;
INSERT INTO job_required_skills (job_posting_id, trait_id)
SELECT j.id, t.id FROM job_postings j JOIN traits t ON t.normalized IN ('go', 'postgresql')
WHERE j.title = 'Integration Backend Engineer' ON CONFLICT DO NOTHING;
INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename)
VALUES ('Integration', 'Candidate', '1992-04-15', 'candidate.integration@example.com',
'+32 470 00 00 99', convert_to('Backend engineer using Go and PostgreSQL.', 'UTF8'), 'integration-cv.txt')
ON CONFLICT (email) DO NOTHING;
INSERT INTO participant_traits (participant_id, trait_id)
SELECT p.id, t.id FROM participants p JOIN traits t ON t.normalized IN ('backend', 'go', 'postgresql')
WHERE p.email = 'candidate.integration@example.com' ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,272 @@
//go:build integration
package integration
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/pressly/goose/v3"
"github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/database/seed"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service"
"github.com/danyel/go-guess/backend/internal/web/handler"
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
"github.com/danyel/go-guess/backend/internal/web/router"
)
var testServer *httptest.Server
func TestMain(m *testing.M) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := tcpostgres.Run(
ctx,
"postgres:18-alpine",
tcpostgres.WithDatabase("go_guess_test"),
tcpostgres.WithUsername("go_guess"),
tcpostgres.WithPassword("go_guess"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
),
)
if err != nil {
fmt.Fprintln(os.Stderr, "start PostgreSQL test container:", err)
os.Exit(1)
}
databaseURL, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
fmt.Fprintln(os.Stderr, "get PostgreSQL connection string:", err)
os.Exit(1)
}
sqlDB, err := sql.Open("pgx", databaseURL)
if err != nil {
fmt.Fprintln(os.Stderr, "open PostgreSQL:", err)
_ = container.Terminate(context.Background())
os.Exit(1)
}
if err := goose.SetDialect("postgres"); err != nil {
fmt.Fprintln(os.Stderr, "set Goose dialect:", err)
os.Exit(1)
}
_, filename, _, _ := runtime.Caller(0)
migrations := filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
if err := goose.Up(sqlDB, migrations); err != nil {
fmt.Fprintln(os.Stderr, "apply migrations:", err)
os.Exit(1)
}
if err := seed.Apply(ctx, sqlDB, "test"); err != nil {
fmt.Fprintln(os.Stderr, "apply test seed:", err)
os.Exit(1)
}
if err := seed.Apply(ctx, sqlDB, "test"); err != nil {
fmt.Fprintln(os.Stderr, "reapply idempotent test seed:", err)
os.Exit(1)
}
gormDB, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
if err != nil {
fmt.Fprintln(os.Stderr, "open GORM connection:", err)
os.Exit(1)
}
store := repository.New(gormDB)
tokens := security.NewTokenManager("integration-test-secret-at-least-32-bytes", time.Hour)
invitations := service.NewInvitationService(store, "http://example.test")
testServer = httptest.NewServer(router.New(
handler.New(
service.NewAuthService(store, tokens),
service.NewJobService(store),
service.NewQuestionService(store),
service.NewParticipantService(store),
invitations,
10,
"http://example.test",
),
tokens,
"http://example.test",
))
code := m.Run()
testServer.Close()
_ = sqlDB.Close()
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = container.Terminate(cleanupCtx)
cleanupCancel()
os.Exit(code)
}
func TestCompleteInterviewWorkflow(t *testing.T) {
var login webmodel.LoginResponse
requestJSON(t, http.MethodPost, "/api/auth/login", "", map[string]any{
"email": "integration@example.com", "password": "integration-password",
}, http.StatusOK, &login)
if login.Token == "" {
t.Fatal("login did not return a token")
}
var participants []webmodel.ParticipantResponse
requestJSON(t, http.MethodGet, "/api/participants", login.Token, nil, http.StatusOK, &participants)
var participantID uint
for _, participant := range participants {
if participant.Email == "candidate.integration@example.com" {
participantID = participant.ID
}
}
if participantID == 0 {
t.Fatal("test participant was not seeded")
}
var question webmodel.QuestionResponse
requestJSON(t, http.MethodPost, "/api/questions", login.Token, map[string]any{
"text": "Review this error handling.", "type": "code_review",
"codeSnippet": "func run() error {\n execute()\n return nil\n}",
"referenceAnswer": "Return the execute error.", "options": []string{},
}, http.StatusCreated, &question)
requestJSON(t, http.MethodPut, fmt.Sprintf("/api/questions/%d", question.ID), login.Token, map[string]any{
"text": "Review this ignored error.", "type": "code_review",
"codeSnippet": "func run() error {\n execute()\n return nil\n}",
"referenceAnswer": "Propagate the execute error.", "options": []string{},
}, http.StatusOK, &question)
var jobs []webmodel.JobResponse
requestJSON(t, http.MethodGet, "/api/jobs", login.Token, nil, http.StatusOK, &jobs)
var jobID uint
for _, job := range jobs {
if job.Title == "Integration Backend Engineer" {
jobID = job.ID
}
}
if jobID == 0 {
t.Fatal("test job was not seeded")
}
requestJSON(t, http.MethodPost,
fmt.Sprintf("/api/jobs/%d/questions/%d", jobID, question.ID),
login.Token, nil, http.StatusNoContent, nil)
var matches []webmodel.CandidateResponse
requestJSON(t, http.MethodGet, fmt.Sprintf("/api/jobs/%d/candidates", jobID),
login.Token, nil, http.StatusOK, &matches)
if len(matches) == 0 || matches[0].Participant.ID != participantID {
t.Fatalf("seeded participant was not a candidate: %#v", matches)
}
var invitation webmodel.InvitationResponse
requestJSON(t, http.MethodPost, fmt.Sprintf("/api/jobs/%d/invitations", jobID),
login.Token, map[string]any{"participantId": participantID}, http.StatusCreated, &invitation)
if invitation.Token == "" || invitation.ParticipantURL != "http://example.test/participant/"+invitation.Token {
t.Fatalf("unexpected invitation: %#v", invitation)
}
var welcome webmodel.InterviewResponse
publicBody := requestJSON(t, http.MethodGet, "/api/interviews/"+invitation.Token,
"", nil, http.StatusOK, &welcome)
if bytes.Contains(publicBody, []byte("referenceAnswer")) || welcome.Invitation.Status != "pending" {
t.Fatalf("invalid public welcome response: %s", publicBody)
}
var started webmodel.InterviewResponse
requestJSON(t, http.MethodPost, "/api/interviews/"+invitation.Token+"/accept",
"", nil, http.StatusOK, &started)
if started.Invitation.Status != "accepted" || started.Invitation.AcceptedAt == nil {
t.Fatalf("interview was not accepted: %#v", started.Invitation)
}
answer := "The execute error must be returned to the caller."
requestJSON(t, http.MethodPut,
fmt.Sprintf("/api/interviews/%s/answers/%d", invitation.Token, question.ID),
"", map[string]any{"answer": answer}, http.StatusNoContent, nil)
requestJSON(t, http.MethodPost, "/api/interviews/"+invitation.Token+"/finish",
"", nil, http.StatusOK, &started)
if started.Invitation.Status != "completed" {
t.Fatalf("interview was not completed: %#v", started.Invitation)
}
var review webmodel.InvitationReviewResponse
requestJSON(t, http.MethodGet,
fmt.Sprintf("/api/jobs/%d/invitations/%d", jobID, invitation.ID),
login.Token, nil, http.StatusOK, &review)
if review.Answers[question.ID] != answer {
t.Fatalf("saved answer missing from review: %#v", review.Answers)
}
found := false
for _, item := range review.Job.Questions {
if item.ID == question.ID {
found = item.ReferenceAnswer == "Propagate the execute error." && item.CodeSnippet != ""
}
}
if !found {
t.Fatalf("review did not include interviewer question context: %#v", review.Job.Questions)
}
}
func requestJSON(
t *testing.T,
method string,
path string,
token string,
body any,
wantStatus int,
target any,
) []byte {
t.Helper()
var requestBody io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
requestBody = bytes.NewReader(encoded)
}
request, err := http.NewRequest(method, testServer.URL+path, requestBody)
if err != nil {
t.Fatal(err)
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
content, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != wantStatus {
t.Fatalf("%s %s: expected %d, got %d: %s", method, path, wantStatus, response.StatusCode, content)
}
if target != nil {
if err := json.Unmarshal(content, target); err != nil {
t.Fatalf("decode %s %s: %v: %s", method, path, err, content)
}
}
return content
}

View file

@ -80,49 +80,6 @@ CREATE TABLE participant_traits (
PRIMARY KEY (participant_id, trait_id) PRIMARY KEY (participant_id, trait_id)
); );
INSERT INTO users (email, password_hash, role)
VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer');
INSERT INTO traits (name, normalized) VALUES
('Go', 'go'), ('PostgreSQL', 'postgresql'), ('Docker', 'docker'),
('React', 'react'), ('TypeScript', 'typescript'), ('Kubernetes', 'kubernetes'),
('Backend', 'backend');
INSERT INTO job_postings (title, description, seniority, position) VALUES
('Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', 'Senior', 'Backend Engineer'),
('Full-stack Product Engineer', 'Deliver accessible operational tools from database to browser.', 'Medior', 'Full-stack Engineer');
INSERT INTO job_posting_labels (job_posting_id, trait_id)
SELECT 1, id FROM traits WHERE normalized IN ('backend', 'go')
ON CONFLICT DO NOTHING;
INSERT INTO job_required_skills (job_posting_id, trait_id)
SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker');
INSERT INTO job_additional_skills (job_posting_id, trait_id)
SELECT 1, id FROM traits WHERE normalized = 'kubernetes';
INSERT INTO job_posting_labels (job_posting_id, trait_id)
SELECT 2, id FROM traits WHERE normalized IN ('react', 'typescript');
INSERT INTO job_required_skills (job_posting_id, trait_id)
SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript');
INSERT INTO job_additional_skills (job_posting_id, trait_id)
SELECT 2, id FROM traits WHERE normalized = 'docker';
INSERT INTO questions (job_id, text, type) VALUES
(1, 'How do you design graceful shutdown for an HTTP service?', 'open'),
(1, 'Which construct protects shared data across goroutines?', 'radio'),
(2, 'Review this component and identify unnecessary renders.', 'code_review');
INSERT INTO question_options (question_id, text) VALUES
(2, 'sync.Mutex'), (2, 'context.Context'), (2, 'http.Client');
INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename) VALUES
('Ada', 'Lovelace', '1990-12-10', 'ada@example.com', '+32 470 00 00 01', encode('Senior backend engineer with Go, PostgreSQL and Docker experience.', 'escape')::bytea, 'ada-cv.txt'),
('Grace', 'Hopper', '1988-12-09', 'grace@example.com', '+32 470 00 00 02', encode('Full-stack developer working with Go, React and TypeScript.', 'escape')::bytea, 'grace-cv.txt');
INSERT INTO participant_traits (participant_id, trait_id)
SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker');
INSERT INTO participant_traits (participant_id, trait_id)
SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript');
-- +goose Down -- +goose Down
DROP TABLE IF EXISTS participant_traits; DROP TABLE IF EXISTS participant_traits;
DROP TABLE IF EXISTS job_additional_skills; DROP TABLE IF EXISTS job_additional_skills;

View file

@ -2,12 +2,5 @@
ALTER TABLE questions ALTER TABLE questions
ADD COLUMN reference_answer TEXT NOT NULL DEFAULT ''; ADD COLUMN reference_answer TEXT NOT NULL DEFAULT '';
UPDATE questions
SET reference_answer = CASE
WHEN type = 'open' THEN 'Use context cancellation, stop accepting work, drain active requests, and close dependencies within a deadline.'
WHEN type = 'code_review' THEN 'Identify unstable dependencies or state updates that cause avoidable renders and propose memoization only where measured.'
ELSE reference_answer
END;
-- +goose Down -- +goose Down
ALTER TABLE questions DROP COLUMN reference_answer; ALTER TABLE questions DROP COLUMN reference_answer;

View file

@ -2,9 +2,5 @@
ALTER TABLE questions ALTER TABLE questions
ADD COLUMN code_snippet TEXT NOT NULL DEFAULT ''; ADD COLUMN code_snippet TEXT NOT NULL DEFAULT '';
UPDATE questions
SET code_snippet = E'function CandidateList({ candidates }) {\n return candidates.map(candidate => (\n <CandidateCard candidate={candidate} />\n ));\n}'
WHERE type = 'code_review' AND code_snippet = '';
-- +goose Down -- +goose Down
ALTER TABLE questions DROP COLUMN code_snippet; ALTER TABLE questions DROP COLUMN code_snippet;

View file

@ -1,125 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service/model"
"github.com/danyel/go-guess/backend/internal/web/handler"
"github.com/danyel/go-guess/backend/internal/web/router"
)
type mockInvitationService struct{}
func (m mockInvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) {
return []model.Invitation{{ID: 1}}, nil
}
func (m mockInvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) {
return model.Invitation{}, nil
}
func (m mockInvitationService) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) {
return model.Interview{
Invitation: model.Invitation{ID: 1, ParticipantName: "Test", ParticipantEmail: "test@example.com", Status: "completed"},
Job: model.JobPosting{ID: 1, Title: "Job"},
Answers: map[uint]string{1: "My answer"},
}, nil
}
func (m mockInvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
return model.Interview{}, nil
}
func (m mockInvitationService) Accept(ctx context.Context, token string) (model.Interview, error) {
return model.Interview{}, nil
}
func (m mockInvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
return nil
}
func (m mockInvitationService) Finish(ctx context.Context, token string) (model.Interview, error) {
return model.Interview{}, nil
}
type mockStubs struct{}
func (mockStubs) Login(context.Context, string, string) (string, model.User, error) {
return "", model.User{}, nil
}
func (mockStubs) List(context.Context) ([]model.JobPosting, error) {
return []model.JobPosting{}, nil
}
func (mockStubs) Get(context.Context, uint) (model.JobPosting, error) {
return model.JobPosting{}, nil
}
func (mockStubs) Create(context.Context, model.JobPosting) (model.JobPosting, error) {
return model.JobPosting{}, nil
}
func (mockStubs) Update(context.Context, uint, string, int) (model.JobPosting, error) {
return model.JobPosting{}, nil
}
func (mockStubs) AttachQuestion(context.Context, uint, uint) error { return nil }
func (mockStubs) DetachQuestion(context.Context, uint, uint) error { return nil }
func (mockStubs) Candidates(context.Context, uint) ([]model.CandidateMatch, error) {
return []model.CandidateMatch{}, nil
}
func (mockStubs) List2(context.Context) ([]model.Participant, error) { return nil, nil }
func (mockStubs) Get2(context.Context, uint) (model.Participant, error) {
return model.Participant{}, nil
}
func (mockStubs) Create2(context.Context, model.Participant) (model.Participant, error) {
return model.Participant{}, nil
}
func (mockStubs) List3(context.Context, string) ([]model.Question, error) { return nil, nil }
func (mockStubs) Create3(context.Context, model.Question) (model.Question, error) {
return model.Question{}, nil
}
func (mockStubs) Update3(context.Context, uint, model.Question) (model.Question, error) {
return model.Question{}, nil
}
func (mockStubs) SetDeprecated(context.Context, uint, bool) (model.Question, error) {
return model.Question{}, nil
}
func TestReviewEndpoint(t *testing.T) {
// Setup
mockAuth := mockStubs{}
mockJobs := mockStubs{}
mockQuestions := mockStubs{}
mockParticipants := mockStubs{}
mockInvitations := mockInvitationService{}
h := handler.New(mockAuth, mockJobs, mockQuestions, mockParticipants, mockInvitations, 10, "http://localhost:5173")
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
r := router.New(h, tokens, "http://localhost:5173")
// Create a valid token
token, err := tokens.Issue(1, "test@example.com", "interviewer")
if err != nil {
t.Fatalf("Failed to create token: %v", err)
}
// Test the review endpoint
req := httptest.NewRequest(http.MethodGet, "/api/jobs/1/invitations/1", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected 200, got %d. Response: %s", w.Code, w.Body.String())
}
if w.Header().Get("Content-Type") != "application/json" {
t.Errorf("Expected JSON content type, got %s", w.Header().Get("Content-Type"))
}
}

View file

@ -20,7 +20,8 @@ services:
environment: environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
JWT_SECRET: local-development-secret-change-me-12345 JWT_SECRET: local-development-secret-change-me-12345
FRONTEND_URL: http://localhost:5173 FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
APP_ENV: ${APP_ENV:-development}
ports: ports:
- "${API_PORT:-8080}:8080" - "${API_PORT:-8080}:8080"
depends_on: depends_on: