diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7d3a698..d2c66187 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,11 +49,11 @@ jobs: run: go vet ./... backend-postgres: - name: Backend (PostgreSQL concurrency) + name: Backend (PostgreSQL) runs-on: ubuntu-latest services: postgres: - image: postgres:16-alpine + image: pgvector/pgvector:pg16 env: POSTGRES_DB: gochat_test POSTGRES_USER: postgres @@ -76,7 +76,67 @@ jobs: env: GOCHAT_TEST_DB: postgres GOCHAT_TEST_DB_URL: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable - run: go test ./internal/autoassignment -run '^TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres$' -count=3 + run: | + set -euo pipefail + output="$RUNNER_TEMP/autoassignment-concurrency.json" + go test -json ./internal/autoassignment -run '^TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres$' -count=3 | tee "$output" + grep -Eq '"Action":"pass".*"Test":"TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres"' "$output" || { + echo "PostgreSQL auto-assignment concurrency test did not run" >&2 + exit 1 + } + - name: Test PostgreSQL E2E + working-directory: backend + env: + GOCHAT_TEST_DB: postgres + GOCHAT_TEST_DB_URL: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable + run: | + set -euo pipefail + output="$RUNNER_TEMP/postgres-e2e.json" + go test -json -count=1 -timeout 10m ./tests/e2e/... | tee "$output" + + python3 - "$output" <<'PY' + import json + import sys + + required = { + "TestAuthE2ESuite", + "TestAccountCRUDE2ESuite", + "TestConversationE2ESuite", + "TestCRME2ESuite", + "TestCSRFE2ETestSuite", + "TestRBACE2ESuite", + } + results = {} + with open(sys.argv[1], encoding="utf-8") as events: + for line in events: + event = json.loads(line) + if event.get("Test") and event.get("Action") in {"pass", "skip", "fail"}: + results[event["Test"]] = event["Action"] + + missing = sorted(suite for suite in required if results.get(suite) != "pass") + passed_subtests = {suite: 0 for suite in required} + skipped_subtests = {suite: 0 for suite in required} + for test, action in results.items(): + suite, separator, _ = test.partition("/") + if separator and suite in required: + if action == "pass": + passed_subtests[suite] += 1 + elif action == "skip": + skipped_subtests[suite] += 1 + + empty = sorted(suite for suite, count in passed_subtests.items() if count == 0) + for suite in sorted(required): + print(f"{suite}: {passed_subtests[suite]} passed, {skipped_subtests[suite]} skipped") + print( + "E2E totals: " + f"{sum(action == 'pass' for action in results.values())} passed, " + f"{sum(action == 'skip' for action in results.values())} skipped" + ) + if missing: + raise SystemExit(f"required E2E suites did not pass: {', '.join(missing)}") + if empty: + raise SystemExit(f"required E2E suites ran no passing subtests: {', '.join(empty)}") + PY frontend: name: Frontend Build diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index fabc4d3b..7c912dd2 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" @@ -38,12 +39,9 @@ func RunMigrations(dbURL string, migrationsPath string) error { } func runMigrations(dbURL string, migrationsPath string) error { - m, err := migrate.New( - fmt.Sprintf("file://%s", migrationsPath), - dbURL, - ) + m, err := newMigrate(dbURL, migrationsPath) if err != nil { - return fmt.Errorf("failed to create migrate instance: %w", err) + return err } defer m.Close() @@ -95,12 +93,9 @@ func withSanitizedPQEnvironment(fn func() error) error { // MigrateSteps applies N migration steps (positive = up, negative = down). func MigrateSteps(dbURL string, migrationsPath string, steps int) error { - m, err := migrate.New( - fmt.Sprintf("file://%s", migrationsPath), - dbURL, - ) + m, err := newMigrate(dbURL, migrationsPath) if err != nil { - return fmt.Errorf("failed to create migrate instance: %w", err) + return err } defer m.Close() @@ -116,16 +111,9 @@ func MigrateSteps(dbURL string, migrationsPath string, steps int) error { // RollbackMigrations rolls back all migrations (drops all tables). func RollbackMigrations(dbURL string, migrationsPath string) error { - if migrationsPath == "" { - return errors.New("migrations path is required") - } - - m, err := migrate.New( - fmt.Sprintf("file://%s", migrationsPath), - dbURL, - ) + m, err := newMigrate(dbURL, migrationsPath) if err != nil { - return fmt.Errorf("failed to create migrate instance: %w", err) + return err } defer m.Close() @@ -142,12 +130,9 @@ func RollbackMigrations(dbURL string, migrationsPath string) error { // ForceVersion sets the migration version to a specific number, clearing dirty state. // Use this to recover from a partially-applied migration. func ForceVersion(dbURL string, migrationsPath string, version int) error { - m, err := migrate.New( - fmt.Sprintf("file://%s", migrationsPath), - dbURL, - ) + m, err := newMigrate(dbURL, migrationsPath) if err != nil { - return fmt.Errorf("failed to create migrate instance: %w", err) + return err } defer m.Close() @@ -161,12 +146,9 @@ func ForceVersion(dbURL string, migrationsPath string, version int) error { // CurrentVersion returns the current migration version and whether the DB is in a dirty state. // When no migrations have been applied (version 0), returns (0, false, nil) instead of an error. func CurrentVersion(dbURL string, migrationsPath string) (uint, bool, error) { - m, err := migrate.New( - fmt.Sprintf("file://%s", migrationsPath), - dbURL, - ) + m, err := newMigrate(dbURL, migrationsPath) if err != nil { - return 0, false, fmt.Errorf("failed to create migrate instance: %w", err) + return 0, false, err } defer m.Close() @@ -181,3 +163,15 @@ func CurrentVersion(dbURL string, migrationsPath string) (uint, bool, error) { return version, dirty, nil } + +func newMigrate(dbURL, migrationsPath string) (*migrate.Migrate, error) { + if strings.TrimSpace(migrationsPath) == "" { + return nil, errors.New("migration path must not be empty") + } + + m, err := migrate.New(fmt.Sprintf("file://%s", migrationsPath), dbURL) + if err != nil { + return nil, fmt.Errorf("failed to create migrate instance: %w", err) + } + return m, nil +} diff --git a/backend/internal/database/migrate_test.go b/backend/internal/database/migrate_test.go index 2a841204..e4d214b2 100644 --- a/backend/internal/database/migrate_test.go +++ b/backend/internal/database/migrate_test.go @@ -7,6 +7,25 @@ import ( "github.com/stretchr/testify/require" ) +func TestMigrationCommandsRejectBlankPath(t *testing.T) { + tests := map[string]func() error{ + "run": func() error { return RunMigrations("invalid-url", " \t") }, + "steps": func() error { return MigrateSteps("invalid-url", " \t", 1) }, + "rollback": func() error { return RollbackMigrations("invalid-url", " \t") }, + "force": func() error { return ForceVersion("invalid-url", " \t", 1) }, + "version": func() error { + _, _, err := CurrentVersion("invalid-url", " \t") + return err + }, + } + + for name, run := range tests { + t.Run(name, func(t *testing.T) { + require.EqualError(t, run(), "migration path must not be empty") + }) + } +} + func TestWithSanitizedPQEnvironment(t *testing.T) { t.Setenv("PGSERVICE", "local-service") t.Setenv("PGSERVICEFILE", "/tmp/pg_service.conf")