package database import ( "context" "database/sql" "io" "log/slog" "os" "strings" "testing" "time" "github.com/google/uuid" ) func TestPostgresMigrationAndMaintenanceIntegration(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) if dsn == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := Open(ctx, dsn, logger) if err != nil { t.Fatal(err) } defer Close(db) sqlDB, err := SQLDB(db) if err != nil { t.Fatal(err) } if err := Migrate(ctx, sqlDB, logger); err != nil { t.Fatal(err) } if err := Migrate(ctx, sqlDB, logger); err != nil { t.Fatalf("second migration run must be idempotent: %v", err) } if err := SchemaReady(ctx, sqlDB); err != nil { t.Fatalf("schema readiness check failed after migration: %v", err) } var migrationCount int if err := sqlDB.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil { t.Fatal(err) } if migrationCount < 14 { t.Fatalf("migration count = %d, want at least 14", migrationCount) } var experiencedPeopleStartColumn string if err := sqlDB.QueryRowContext(ctx, ` SELECT is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'projects' AND column_name = 'experienced_people_start'`).Scan(&experiencedPeopleStartColumn); err != nil { t.Fatal(err) } if experiencedPeopleStartColumn != "NO" { t.Fatalf("experienced_people_start is_nullable = %q, want NO", experiencedPeopleStartColumn) } var partySizeNullable string if err := sqlDB.QueryRowContext(ctx, ` SELECT is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'queue_tickets' AND column_name = 'party_size'`).Scan(&partySizeNullable); err != nil { t.Fatal(err) } if partySizeNullable != "NO" { t.Fatalf("party_size is_nullable = %q, want NO", partySizeNullable) } var nullable string if err := sqlDB.QueryRowContext(ctx, ` SELECT is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'queue_tickets' AND column_name = 'phone_hmac'`).Scan(&nullable); err != nil { t.Fatal(err) } if nullable != "YES" { t.Fatalf("phone_hmac is_nullable = %q, want YES after purge migration", nullable) } var historyAnonymizedNullable string if err := sqlDB.QueryRowContext(ctx, ` SELECT is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'queue_tickets' AND column_name = 'history_anonymized_at'`).Scan(&historyAnonymizedNullable); err != nil { t.Fatal(err) } if historyAnonymizedNullable != "YES" { t.Fatalf("history_anonymized_at is_nullable = %q, want YES", historyAnonymizedNullable) } if _, err := sqlDB.ExecContext(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE username = $2`, "rotated-hash", "xqkwljtadmin"); err != nil { t.Fatalf("protected super admin password rotation should be allowed: %v", err) } if _, err := sqlDB.ExecContext(ctx, `UPDATE users SET role = 'STAFF' WHERE username = $1`, "xqkwljtadmin"); err == nil { t.Fatal("protected super admin role change should be rejected") } fixture := newMaintenanceFixture(t, ctx, sqlDB) stats, err := RunMaintenanceOnce(ctx, sqlDB, time.Now().UTC(), 100) if err != nil { t.Fatal(err) } if stats.PersonalDataPurged != 1 || stats.HistoryAnonymized != 1 || stats.IdempotencyDeleted != 1 || stats.SessionsDeleted != 1 || stats.AuditDeleted != 1 { t.Fatalf("maintenance stats = %#v, want one row in each category", stats) } var phoneCiphertext, phoneNonce, phoneHMAC, purgedAt any if err := sqlDB.QueryRowContext(ctx, ` SELECT phone_ciphertext, phone_nonce, phone_hmac, personal_data_purged_at FROM queue_tickets WHERE id = $1`, fixture.ticketID). Scan(&phoneCiphertext, &phoneNonce, &phoneHMAC, &purgedAt); err != nil { t.Fatal(err) } if phoneCiphertext != nil || phoneNonce != nil || phoneHMAC != nil || purgedAt == nil { t.Fatalf("purged ticket fields = %#v/%#v/%#v/%#v", phoneCiphertext, phoneNonce, phoneHMAC, purgedAt) } var historyPhoneCiphertext, historyPhoneNonce, historyPhoneHMAC, historyLastNameCiphertext, historyLastNameNonce, historyPurgedAt, historyAnonymizedAt any var historyHonorific string if err := sqlDB.QueryRowContext(ctx, ` SELECT phone_ciphertext, phone_nonce, phone_hmac, last_name_ciphertext, last_name_nonce, personal_data_purged_at, history_anonymized_at, honorific FROM queue_tickets WHERE id = $1`, fixture.historyTicketID). Scan(&historyPhoneCiphertext, &historyPhoneNonce, &historyPhoneHMAC, &historyLastNameCiphertext, &historyLastNameNonce, &historyPurgedAt, &historyAnonymizedAt, &historyHonorific); err != nil { t.Fatal(err) } if historyPhoneCiphertext != nil || historyPhoneNonce != nil || historyPhoneHMAC != nil || historyLastNameCiphertext != nil || historyLastNameNonce != nil || historyPurgedAt == nil || historyAnonymizedAt == nil || historyHonorific != "游客" { t.Fatalf("historical anonymization fields = %#v/%#v/%#v/%#v/%#v/%#v/%#v/%q", historyPhoneCiphertext, historyPhoneNonce, historyPhoneHMAC, historyLastNameCiphertext, historyLastNameNonce, historyPurgedAt, historyAnonymizedAt, historyHonorific) } } type maintenanceFixture struct { userID string projectID string ticketID string historyTicketID string } func newMaintenanceFixture(t *testing.T, ctx context.Context, db *sql.DB) maintenanceFixture { t.Helper() now := time.Now().UTC().Truncate(time.Microsecond) userID, projectID, sessionID, ticketID, historyTicketID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() code := "MNT" + strings.ToUpper(strings.ReplaceAll(userID[:5], "-", "")) if _, err := db.ExecContext(ctx, ` INSERT INTO users (id, username, password_hash, role, active) VALUES ($1, $2, $3, 'ADMIN', true)`, userID, "maintenance_"+userID[:8], "not-a-real-hash"); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO projects ( id, code, name, status, timezone, ticket_prefix, call_batch_size, grace_period_minutes, eta_mode, average_batch_interval_seconds, continuous_rate_per_minute, eta_buffer_minutes, device_simulation_mode ) VALUES ($1, $2, 'Maintenance Test', 'ENDED', 'Asia/Shanghai', 'M', 1, 0, 'FIXED_BATCH', 60, 1, 0, 'DISABLED')`, projectID, code); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO queue_sessions (id, project_id, business_date, status, next_ticket_number, revision, opened_at, closed_at) VALUES ($1, $2, '2020-01-01', 'ENDED', 2, 1, $3, $3)`, sessionID, projectID, now.Add(-48*time.Hour)); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO queue_tickets ( id, project_id, queue_session_id, ticket_number, display_number, public_token_hash, phone_ciphertext, phone_nonce, phone_hmac, honorific, status, joined_at, completed_at, personal_data_purge_at, created_by ) VALUES ($1, $2, $3, 1, '00001', $4, $5, $6, $7, '女士', 'COMPLETED', $8, $8, $9, $10)`, ticketID, projectID, sessionID, strings.Repeat("a", 64), []byte("ciphertext"), make([]byte, 12), strings.Repeat("b", 64), now.Add(-72*time.Hour), now.Add(-time.Hour), userID); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO queue_tickets ( id, project_id, queue_session_id, ticket_number, display_number, public_token_hash, phone_ciphertext, phone_nonce, phone_hmac, last_name_ciphertext, last_name_nonce, honorific, status, joined_at, completed_at, personal_data_purge_at, created_by ) VALUES ($1, $2, $3, 2, '00002', $4, $5, $6, $7, $8, $9, '女士', 'COMPLETED', $10, $10, $11, $12)`, historyTicketID, projectID, sessionID, strings.Repeat("e", 64), []byte("history-ciphertext"), make([]byte, 12), strings.Repeat("f", 64), []byte("history-last-name"), make([]byte, 12), now.Add(-100*24*time.Hour), now.Add(30*24*time.Hour), userID); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO idempotency_keys (project_id, user_id, scope, key, request_hash, response_code, response_body, expires_at) VALUES ($1, $2, 'TEST', $3, $4, 200, '{}', $5)`, projectID, userID, "expired-"+userID, strings.Repeat("c", 64), now.Add(-time.Hour)); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO auth_sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES ($1, $2, $3, $4, $4)`, uuid.NewString(), userID, strings.Repeat("d", 64), now.Add(-time.Hour)); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, ` INSERT INTO audit_entries (project_id, actor_user_id, action, entity_type, details, request_id, user_agent, retain_until) VALUES ($1, $2, 'TEST', 'TEST', '{}', $3, 'integration-test', $4)`, projectID, userID, "request-"+userID, now.Add(-time.Hour)); err != nil { t.Fatal(err) } return maintenanceFixture{userID: userID, projectID: projectID, ticketID: ticketID, historyTicketID: historyTicketID} }