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

@ -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"]

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/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
)

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.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=

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)
);
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;

View file

@ -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;

View file

@ -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 <CandidateCard candidate={candidate} />\n ));\n}'
WHERE type = 'code_review' AND code_snippet = '';
-- +goose Down
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"))
}
}