diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 48609ff..b409c16 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -5,97 +5,72 @@
Run repository-wide commands from the root:
```bash
-make dev # build and run PostgreSQL, API, and frontend
-make db-up # start only PostgreSQL
-make migrate # apply Goose migrations
-make migrate-down # roll back one migration
-make test # backend and frontend unit/component tests
-make test-integration # PostgreSQL-backed repository tests; database must be running
+make dev # run PostgreSQL, API, and frontend with development fixtures
+make test # complete suite, including disposable PostgreSQL integration tests
+make test-integration # Go tests; starts its own PostgreSQL Testcontainer
+make test-env # isolated seeded UI stack at http://localhost:15173
+make test-env-down # remove the test stack and its PostgreSQL volume
make lint # go vet and ESLint
make build # compile the API and production frontend
```
-Run focused tests from the owning application:
+Focused tests:
```bash
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"
```
-The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Frontend
-formatting can be checked with `cd frontend && npm run format:check`; backend files
-are formatted with `gofmt`.
+The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
+`cd frontend && npm run format:check` for frontend formatting and `gofmt` for Go.
## 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,
- services, handlers, and routing. Requests flow through
- `internal/web/{router,handler,model,mapper}` to
- `internal/service/{model,services}` and then
- `internal/database/{repository,mapper,entity}`. Do not bypass these mapping
- 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 files in `backend/migrations` are schema-only and run in every environment.
+`internal/database/seed/common.sql` contains shared reference data;
+`development.sql` and `test.sql` contain idempotent environment-specific fixtures.
+Every entity change requires a migration plus corresponding fixture and integration
+test updates. Never put demo users or test records in schema migrations.
-Goose SQL files in `backend/migrations/` are the database schema source of truth.
-The initial migration also supplies representative jobs, questions, participants,
-traits, and the local interviewer account.
+Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
+protected except `/api/health`, `/api/auth/login`, and opaque-token participant
+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,
-`POST /api/auth/login` issues an HMAC-signed JWT, and all domain routes require that
-token. Never return password hashes or participant photo/CV bytes in JSON; those
-files have dedicated endpoints.
+Questions are permanent reusable records linked to jobs through
+`job_posting_questions`; detach links rather than deleting questions. Deprecated
+questions remain searchable but cannot be newly attached. Question values are
+`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
-DOCX input. Trait extraction only considers normalized traits already present in
-the database. Candidate matching compares a participant's extracted traits with a
-job's labels, required skills, and additional skills; include matches only at the
-`service.CandidateThreshold` of 60% or higher.
-
-Questions are permanent, reusable library records and jobs own many-to-many links
-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.
+Jobs move through `draft`, `published`, and `deprecated`. Publishing requires a
+question, only published jobs can create invitations, and candidate matching uses
+labels plus both skill groups with a 60% threshold. Invitation URLs use
+`/participant/:invitationId`, where the identifier is an opaque token. Participant
+questions are acceptance-gated and answers are saved before final submission;
+interviewer review uses
+`GET /api/jobs/{id}/invitations/{invitationId}`.
## Repository conventions
-- Every Go interface name starts with `I` (`IStore`, `IJobService`,
- `IParticipantService`, `ITokenManager`).
-- Keep transport, service, and persistence structs separate. Conversion belongs in
- the adjacent `mapper` package, not in handlers or GORM entities.
-- Question types are the exact API values `open`, `multiple_choice`, `radio`, and
- `code_review`. Multiple-choice and radio questions require at least two options.
-- Never delete question records through job operations. Use
- `job_posting_questions` to attach/detach, and use the `deprecated` flag to retire
- library questions. Full question edits use `PUT /api/questions/{id}`; deprecation
- 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.
+- Prefix every Go interface name with `I`.
+- Keep web, service, and persistence models separate and convert them in adjacent
+ `mapper` packages.
+- Participant creation uses multipart fields `firstName`, `lastName`, `birthday`,
+ `email`, `contactInfo`, `photo`, and `cv`.
+- Return explicit errors; map expected failures to HTTP status codes and log
+ unexpected failures instead of silently falling back.
+- Commit every repository change with a meaningful conventional prefix such as
+ `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d450835
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/Makefile b/Makefile
index c642c01..a5db44d 100644
--- a/Makefile
+++ b/Makefile
@@ -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
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:
docker compose up --build
@@ -21,13 +21,19 @@ migrate:
migrate-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:
cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api
frontend:
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:
cd backend && go test ./...
@@ -36,7 +42,14 @@ test-frontend:
cd frontend && npm test
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:
cd backend && go vet ./...
diff --git a/README.md b/README.md
index 62958d3..1a3ab43 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ To run the applications outside containers:
```bash
make db-up
make migrate
+make seed
make backend
make frontend
```
@@ -39,8 +40,8 @@ outside local development.
## Quality commands
```bash
-make test
-make test-integration
+make test # full suite, including disposable PostgreSQL integration tests
+make test-integration # Go integration suite with its own PostgreSQL container
make lint
make build
```
@@ -57,6 +58,19 @@ Run one frontend test:
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
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
during local development.
-Goose migrations in `backend/migrations` are the schema source of truth and include
-representative local data. Uploaded CVs are stored as bytes; plain text, PDF, and
-DOCX content is inspected for normalized traits already defined by job postings.
+Goose migrations in `backend/migrations` are schema-only and run in every
+environment. Embedded SQL under `backend/internal/database/seed` separates shared
+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
editable, permanent library records: jobs attach and detach links rather than
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 5aa5e89..3cd0634 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -3,7 +3,8 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
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
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
WORKDIR /app
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 migrations ./migrations
USER app
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"]
diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go
new file mode 100644
index 0000000..bb873f2
--- /dev/null
+++ b/backend/cmd/seed/main.go
@@ -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)
+ }
+}
diff --git a/backend/go.mod b/backend/go.mod
index 177c5c7..2ecd304 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -6,20 +6,73 @@ require (
github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.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
- golang.org/x/crypto v0.43.0
+ golang.org/x/crypto v0.54.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
)
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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- github.com/stretchr/testify v1.11.0 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/text v0.30.0 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
+ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // 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
)
diff --git a/backend/go.sum b/backend/go.sum
index ebef8a7..f5f8a71 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/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/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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/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.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/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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
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/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/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/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.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
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/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/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/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/backend/internal/database/seed/common.sql b/backend/internal/database/seed/common.sql
new file mode 100644
index 0000000..8e0622f
--- /dev/null
+++ b/backend/internal/database/seed/common.sql
@@ -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;
diff --git a/backend/internal/database/seed/development.sql b/backend/internal/database/seed/development.sql
new file mode 100644
index 0000000..a1a983e
--- /dev/null
+++ b/backend/internal/database/seed/development.sql
@@ -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 \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;
diff --git a/backend/internal/database/seed/seed.go b/backend/internal/database/seed/seed.go
new file mode 100644
index 0000000..0b50150
--- /dev/null
+++ b/backend/internal/database/seed/seed.go
@@ -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
+}
diff --git a/backend/internal/database/seed/test.sql b/backend/internal/database/seed/test.sql
new file mode 100644
index 0000000..a68fe36
--- /dev/null
+++ b/backend/internal/database/seed/test.sql
@@ -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;
diff --git a/backend/internal/integration/api_test.go b/backend/internal/integration/api_test.go
new file mode 100644
index 0000000..5347a4e
--- /dev/null
+++ b/backend/internal/integration/api_test.go
@@ -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
+}
diff --git a/backend/migrations/00001_initial.sql b/backend/migrations/00001_initial.sql
index 568b406..473e2c7 100644
--- a/backend/migrations/00001_initial.sql
+++ b/backend/migrations/00001_initial.sql
@@ -80,49 +80,6 @@ CREATE TABLE participant_traits (
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
DROP TABLE IF EXISTS participant_traits;
DROP TABLE IF EXISTS job_additional_skills;
diff --git a/backend/migrations/00004_question_reference_answer.sql b/backend/migrations/00004_question_reference_answer.sql
index c2f7f3a..06644c8 100644
--- a/backend/migrations/00004_question_reference_answer.sql
+++ b/backend/migrations/00004_question_reference_answer.sql
@@ -2,12 +2,5 @@
ALTER TABLE questions
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
ALTER TABLE questions DROP COLUMN reference_answer;
diff --git a/backend/migrations/00005_question_code_snippet.sql b/backend/migrations/00005_question_code_snippet.sql
index e36b12a..aa049fe 100644
--- a/backend/migrations/00005_question_code_snippet.sql
+++ b/backend/migrations/00005_question_code_snippet.sql
@@ -2,9 +2,5 @@
ALTER TABLE questions
ADD COLUMN code_snippet TEXT NOT NULL DEFAULT '';
-UPDATE questions
-SET code_snippet = E'function CandidateList({ candidates }) {\n return candidates.map(candidate => (\n \n ));\n}'
-WHERE type = 'code_review' AND code_snippet = '';
-
-- +goose Down
ALTER TABLE questions DROP COLUMN code_snippet;
diff --git a/backend/test_review_endpoint.go b/backend/test_review_endpoint.go
deleted file mode 100644
index 41724d9..0000000
--- a/backend/test_review_endpoint.go
+++ /dev/null
@@ -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"))
- }
-}
diff --git a/docker-compose.yml b/docker-compose.yml
index aff52f6..05cf9e6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,7 +20,8 @@ services:
environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
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:
- "${API_PORT:-8080}:8080"
depends_on: