From 025659a190e84984432c345e6bb21ebec94cf383 Mon Sep 17 00:00:00 2001 From: Cheng-Wei Huang Date: Fri, 13 Mar 2026 08:57:47 -0700 Subject: [PATCH 1/5] Fix CI: scope push trigger to main and use migration files for test DB - Scope push trigger to branches: [main] to prevent duplicate CI runs on push + pull_request for the same branch - Replace 350-line static SQL schema in testutil with actual migration runner, preventing test/production schema drift --- .github/workflows/ci.yml | 1 + backend/internal/testutil/database.go | 354 ++------------------------ 2 files changed, 18 insertions(+), 337 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e74c8b0..e15952f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: concurrency: diff --git a/backend/internal/testutil/database.go b/backend/internal/testutil/database.go index 416dee7..9070a60 100644 --- a/backend/internal/testutil/database.go +++ b/backend/internal/testutil/database.go @@ -4,6 +4,8 @@ import ( "ditto-backend/pkg/database" "fmt" "os" + "path/filepath" + "runtime" "testing" "github.com/jmoiron/sqlx" @@ -55,352 +57,31 @@ func getEnvOrDefault(key, defaultValue string) string { return defaultValue } -// RunMigrations runs database migrations on test database +// migrationsPath returns the absolute path to the migrations directory +func migrationsPath() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "..", "..", "migrations") +} + +// RunMigrations drops all tables and runs the real migration files func (td *TestDatabase) RunMigrations(t *testing.T) { - // Drop and recreate all tables to ensure schema is up to date - dropSQL := ` - DROP TABLE IF EXISTS rate_limits CASCADE; - DROP TABLE IF EXISTS user_notification_preferences CASCADE; - DROP TABLE IF EXISTS notifications CASCADE; - DROP TABLE IF EXISTS assessment_submissions CASCADE; - DROP TABLE IF EXISTS assessments CASCADE; - DROP TABLE IF EXISTS interview_notes CASCADE; - DROP TABLE IF EXISTS interview_questions CASCADE; - DROP TABLE IF EXISTS interviewers CASCADE; - DROP TABLE IF EXISTS interviews CASCADE; - DROP TABLE IF EXISTS files CASCADE; - DROP TABLE IF EXISTS applications CASCADE; - DROP TABLE IF EXISTS application_status CASCADE; - DROP TABLE IF EXISTS user_jobs CASCADE; - DROP TABLE IF EXISTS jobs CASCADE; - DROP TABLE IF EXISTS companies CASCADE; - DROP TABLE IF EXISTS user_refresh_tokens CASCADE; - DROP TABLE IF EXISTS users_auth CASCADE; - DROP TABLE IF EXISTS users CASCADE; - ` - _, err := td.Exec(dropSQL) + // Drop everything so migrations run from scratch + _, err := td.Exec(` + DROP SCHEMA public CASCADE; + CREATE SCHEMA public; + GRANT ALL ON SCHEMA public TO CURRENT_USER; + `) if err != nil { - t.Fatalf("Failed to drop tables: %v", err) + t.Fatalf("Failed to reset schema: %v", err) } - migrationSQL := ` - -- Utility function for automatic timestamp updates - CREATE OR REPLACE FUNCTION update_timestamp() - RETURNS TRIGGER AS $$ - BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL - ); - - CREATE TABLE users_auth ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id), - password_hash TEXT NULL, - auth_provider TEXT NOT NULL, - avatar_url TEXT NULL, - provider_email TEXT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT users_auth_user_provider_unique UNIQUE (user_id, auth_provider) - ); - - CREATE TABLE user_refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - refresh_token TEXT NOT NULL, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT user_refresh_tokens_user_unique UNIQUE (user_id) - ); - - CREATE TABLE companies ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL UNIQUE, - description TEXT, - website VARCHAR(255), - logo_url TEXT, - domain VARCHAR(255), - opencorp_id VARCHAR(255), - last_enriched_at TIMESTAMP NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL - ); - - CREATE TABLE jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - company_id UUID NOT NULL REFERENCES companies(id), - title TEXT NOT NULL, - job_description TEXT NOT NULL, - location TEXT NOT NULL, - job_type TEXT, - source_url VARCHAR(2048), - platform VARCHAR(50), - min_salary NUMERIC, - max_salary NUMERIC, - currency TEXT, - is_expired BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL - ); - - CREATE TABLE user_jobs ( - id UUID PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE application_status ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id), - job_id UUID REFERENCES jobs(id), - application_status_id UUID REFERENCES application_status(id), - applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - offer_received BOOLEAN NOT NULL DEFAULT false, - attempt_number INT NOT NULL DEFAULT 1, - notes TEXT, - search_vector tsvector, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL - ); - - CREATE TABLE files ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, - interview_id UUID, - file_name VARCHAR(256) NOT NULL, - file_type VARCHAR(50) NOT NULL, - file_size BIGINT NOT NULL, - s3_key VARCHAR(500) NOT NULL UNIQUE, - uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL - ); - - CREATE INDEX idx_files_user_id ON files(user_id) WHERE deleted_at IS NULL; - CREATE INDEX idx_files_application_id ON files(application_id) WHERE deleted_at IS NULL; - - CREATE TABLE interviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - application_id UUID NOT NULL REFERENCES applications(id), - round_number INT NOT NULL DEFAULT 1, - scheduled_date DATE NOT NULL, - scheduled_time VARCHAR(10), - duration_minutes INT, - status VARCHAR(30) NOT NULL DEFAULT 'scheduled', - outcome VARCHAR(50), - overall_feeling VARCHAR(50), - went_well TEXT, - could_improve TEXT, - confidence_level INT, - interview_type VARCHAR(50) NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE INDEX idx_interviews_user_id ON interviews(user_id) WHERE deleted_at IS NULL; - CREATE INDEX idx_interviews_application_id ON interviews(application_id) WHERE deleted_at IS NULL; - - CREATE TABLE interviewers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - interview_id UUID NOT NULL REFERENCES interviews(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - role VARCHAR(255), - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE TABLE interview_questions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - interview_id UUID NOT NULL REFERENCES interviews(id) ON DELETE CASCADE, - question_text TEXT NOT NULL, - answer_text TEXT, - "order" INT NOT NULL DEFAULT 0, - search_vector tsvector, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE TABLE interview_notes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - interview_id UUID NOT NULL REFERENCES interviews(id) ON DELETE CASCADE, - note_type VARCHAR(50) NOT NULL, - content TEXT, - search_vector tsvector, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE TABLE assessments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - application_id UUID NOT NULL REFERENCES applications(id), - assessment_type VARCHAR(50) NOT NULL, - title VARCHAR(255) NOT NULL, - due_date DATE NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'not_started', - instructions TEXT, - requirements TEXT, - search_vector tsvector, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE TABLE assessment_submissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - assessment_id UUID NOT NULL REFERENCES assessments(id), - submission_type VARCHAR(50) NOT NULL, - github_url VARCHAR(500), - file_id UUID REFERENCES files(id), - notes TEXT, - submitted_at TIMESTAMP DEFAULT NOW(), - created_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE INDEX idx_assessments_user_id ON assessments(user_id) WHERE deleted_at IS NULL; - CREATE INDEX idx_assessments_application_id ON assessments(application_id) WHERE deleted_at IS NULL; - - CREATE TABLE rate_limits ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - resource VARCHAR(100) NOT NULL, - request_count INT NOT NULL DEFAULT 0, - window_start TIMESTAMP NOT NULL, - window_end TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE INDEX idx_rate_limits_user_resource_window - ON rate_limits(user_id, resource, window_start, window_end); - CREATE INDEX idx_rate_limits_window_end ON rate_limits(window_end); - - CREATE TABLE notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - type VARCHAR(50) NOT NULL, - title VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - link VARCHAR(500), - read BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT NOW(), - deleted_at TIMESTAMP - ); - - CREATE TABLE user_notification_preferences ( - user_id UUID PRIMARY KEY REFERENCES users(id), - interview_24h BOOLEAN DEFAULT TRUE, - interview_1h BOOLEAN DEFAULT TRUE, - assessment_3d BOOLEAN DEFAULT TRUE, - assessment_1d BOOLEAN DEFAULT TRUE, - assessment_1h BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ); - - -- Search vector trigger functions - CREATE OR REPLACE FUNCTION applications_search_vector_update() RETURNS trigger AS $$ - BEGIN - NEW.search_vector := to_tsvector('english', coalesce(NEW.notes, '')); - RETURN NEW; - END - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS applications_search_update ON applications; - CREATE TRIGGER applications_search_update - BEFORE INSERT OR UPDATE OF notes ON applications - FOR EACH ROW EXECUTE FUNCTION applications_search_vector_update(); - - CREATE OR REPLACE FUNCTION assessments_search_vector_update() RETURNS trigger AS $$ - BEGIN - NEW.search_vector := - setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || - setweight(to_tsvector('english', coalesce(NEW.instructions, '')), 'B'); - RETURN NEW; - END - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS assessments_search_update ON assessments; - CREATE TRIGGER assessments_search_update - BEFORE INSERT OR UPDATE OF title, instructions ON assessments - FOR EACH ROW EXECUTE FUNCTION assessments_search_vector_update(); - - CREATE OR REPLACE FUNCTION interview_notes_search_vector_update() RETURNS trigger AS $$ - BEGIN - NEW.search_vector := to_tsvector('english', coalesce(NEW.content, '')); - RETURN NEW; - END - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS interview_notes_search_update ON interview_notes; - CREATE TRIGGER interview_notes_search_update - BEFORE INSERT OR UPDATE OF content ON interview_notes - FOR EACH ROW EXECUTE FUNCTION interview_notes_search_vector_update(); - - CREATE OR REPLACE FUNCTION interview_questions_search_vector_update() RETURNS trigger AS $$ - BEGIN - NEW.search_vector := - setweight(to_tsvector('english', coalesce(NEW.question_text, '')), 'A') || - setweight(to_tsvector('english', coalesce(NEW.answer_text, '')), 'B'); - RETURN NEW; - END - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS interview_questions_search_update ON interview_questions; - CREATE TRIGGER interview_questions_search_update - BEFORE INSERT OR UPDATE OF question_text, answer_text ON interview_questions - FOR EACH ROW EXECUTE FUNCTION interview_questions_search_vector_update(); - - -- Insert application statuses (system data, matches production) - INSERT INTO application_status (name) VALUES - ('Saved'), - ('Applied'), - ('Interview'), - ('Offer'), - ('Rejected') - ON CONFLICT DO NOTHING; - ` - - _, err = td.Exec(migrationSQL) - if err != nil { + if err := database.RunMigrations(td.DB, migrationsPath()); err != nil { t.Fatalf("Failed to run migrations: %v", err) } } // Truncate truncates all tables for clean test state func (td *TestDatabase) Truncate(t *testing.T) { - // Drop rate_limits if it exists with wrong ownership (created by a different user) - _, _ = td.Exec("DROP TABLE IF EXISTS rate_limits CASCADE") - tables := []string{ "rate_limits", "user_notification_preferences", @@ -424,7 +105,6 @@ func (td *TestDatabase) Truncate(t *testing.T) { for _, table := range tables { _, err := td.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) if err != nil { - // Skip tables with permission issues (e.g., created by different user) t.Logf("Warning: could not truncate table %s: %v", table, err) } } From 4159c25402b348a83bdec2bac4a919141fdda76a Mon Sep 17 00:00:00 2001 From: Simon Huang Date: Sun, 15 Mar 2026 19:50:38 -0700 Subject: [PATCH 2/5] Separate test infrastructure into docker-compose.test.yml Move test DB and LocalStack out of the main docker-compose.yml into a dedicated test compose file with ephemeral storage. Update run_tests.sh to use -p 1 to prevent parallel DB conflicts. --- backend/run_tests.sh | 4 ++-- docker-compose.test.yml | 26 ++++++++++++++++++++++++++ docker-compose.yml | 24 ------------------------ 3 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 docker-compose.test.yml diff --git a/backend/run_tests.sh b/backend/run_tests.sh index 7706702..f261f25 100755 --- a/backend/run_tests.sh +++ b/backend/run_tests.sh @@ -33,7 +33,7 @@ fi echo "" echo -e "${YELLOW}Running repository tests...${NC}" -if go test ./internal/repository -v; then +if go test -p 1 ./internal/repository -v; then echo -e "${GREEN}✓ Repository tests passed${NC}" else echo -e "${RED}✗ Repository tests failed${NC}" @@ -42,7 +42,7 @@ fi echo "" echo -e "${YELLOW}Running all tests...${NC}" -if go test ./... -v; then +if go test -p 1 ./... -v; then echo -e "${GREEN}✓ All tests passed${NC}" else echo -e "${RED}✗ Some tests failed${NC}" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..c8dfa1b --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,26 @@ +services: + test-db: + image: postgres:15 + container_name: ditto-test-postgres + environment: + POSTGRES_USER: ditto_test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: ditto_test + ports: + - "5432:5432" + tmpfs: + - /var/lib/postgresql/data + + localstack: + image: localstack/localstack:latest + container_name: ditto-test-localstack + ports: + - "4566:4566" + environment: + SERVICES: s3 + DEFAULT_REGION: us-east-1 + AWS_DEFAULT_REGION: us-east-1 + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + volumes: + - ./scripts/init-localstack.sh:/etc/localstack/init/ready.d/init-localstack.sh diff --git a/docker-compose.yml b/docker-compose.yml index 7c0f7d7..e845362 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,14 +9,10 @@ services: POSTGRES_USER: ditto_user POSTGRES_PASSWORD: ditto_password POSTGRES_DB: ditto_dev - # Create test database on init - POSTGRES_MULTIPLE_DATABASES: ditto_dev,ditto_test ports: - "5432:5432" volumes: - db_data:/var/lib/postgresql/data - - ./backend_go/migrations:/docker-entrypoint-initdb.d - - ./scripts/init-test-db.sh:/docker-entrypoint-initdb.d/00-init-test-db.sh healthcheck: test: ["CMD-SHELL", "pg_isready -U ditto_user -d ditto_dev"] interval: 5s @@ -58,29 +54,9 @@ services: - ditto-network command: ["./docker-entrypoint.sh"] - # LocalStack for S3-compatible file storage in development - localstack: - image: localstack/localstack:latest - container_name: ditto-localstack - ports: - - "4566:4566" - environment: - SERVICES: s3 - DEFAULT_REGION: us-east-1 - AWS_DEFAULT_REGION: us-east-1 - AWS_ACCESS_KEY_ID: test - AWS_SECRET_ACCESS_KEY: test - volumes: - - localstack_data:/var/lib/localstack - - ./scripts/init-localstack.sh:/etc/localstack/init/ready.d/init-localstack.sh - networks: - - ditto-network - volumes: db_data: driver: local - localstack_data: - driver: local networks: ditto-network: From e7ba4086ffdab62368cbbbcc028dfa00ad322987 Mon Sep 17 00:00:00 2001 From: Simon Huang Date: Thu, 19 Mar 2026 18:14:22 -0700 Subject: [PATCH 3/5] Redirect www to non-www to fix CORS origin mismatch Requests from www.jobditto.com to jobditto.com/api were blocked by CORS policy since they are different origins. Redirect www visitors to the canonical non-www domain instead of serving both. --- Caddyfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Caddyfile b/Caddyfile index 3b7ceb8..b4bcddf 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,4 +1,8 @@ -{$DOMAIN:localhost}, www.{$DOMAIN:localhost} { +www.{$DOMAIN:localhost} { + redir https://{$DOMAIN}{uri} permanent +} + +{$DOMAIN:localhost} { encode gzip header { From 3e1c781c12eb8a22daf36224a27302bc84f50496 Mon Sep 17 00:00:00 2001 From: Simon Huang Date: Thu, 19 Mar 2026 18:27:52 -0700 Subject: [PATCH 4/5] Fix failing tests: time format assertion and unique constraint conflict - Use assert.Contains for scheduled_time since API returns full ISO format - Use NoteTypeReflection in SoftDelete test to avoid duplicate with earlier NoteTypeGeneral --- backend/internal/handlers/interview_test.go | 2 +- backend/internal/repository/interview_note_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/handlers/interview_test.go b/backend/internal/handlers/interview_test.go index 13f0344..4624ede 100644 --- a/backend/internal/handlers/interview_test.go +++ b/backend/internal/handlers/interview_test.go @@ -425,7 +425,7 @@ func TestInterviewHandler(t *testing.T) { data := resp["data"].(map[string]interface{}) interview := data["interview"].(map[string]interface{}) assert.Equal(t, "technical", interview["interview_type"]) - assert.Equal(t, newTime, interview["scheduled_time"]) + assert.Contains(t, interview["scheduled_time"], newTime) assert.Equal(t, float64(newDuration), interview["duration_minutes"]) assert.Equal(t, newOutcome, interview["outcome"]) assert.Equal(t, newFeeling, interview["overall_feeling"]) diff --git a/backend/internal/repository/interview_note_test.go b/backend/internal/repository/interview_note_test.go index fdebd26..dd9a277 100644 --- a/backend/internal/repository/interview_note_test.go +++ b/backend/internal/repository/interview_note_test.go @@ -187,7 +187,7 @@ func TestInterviewNoteRepository(t *testing.T) { content := "Delete me" note := &models.InterviewNote{ InterviewID: createdInterview.ID, - NoteType: models.NoteTypeGeneral, + NoteType: models.NoteTypeReflection, Content: &content, } created, err := noteRepo.CreateInterviewNote(note) From b7ead6eb4beb17e54d72c5cf0428b95eb3bfdaed Mon Sep 17 00:00:00 2001 From: Simon Huang Date: Thu, 19 Mar 2026 19:29:32 -0700 Subject: [PATCH 5/5] Fix SoftDeleteInterviewNote test: use isolated interview to avoid unique constraint The test was sharing createdInterview with other tests, causing a duplicate key violation on the interview_notes unique type constraint. --- backend/internal/repository/interview_note_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/internal/repository/interview_note_test.go b/backend/internal/repository/interview_note_test.go index dd9a277..10e2935 100644 --- a/backend/internal/repository/interview_note_test.go +++ b/backend/internal/repository/interview_note_test.go @@ -184,10 +184,19 @@ func TestInterviewNoteRepository(t *testing.T) { }) t.Run("SoftDeleteInterviewNote", func(t *testing.T) { + deleteIv := &models.Interview{ + UserID: testUser.ID, + ApplicationID: createdApp.ID, + ScheduledDate: futureDate, + InterviewType: models.InterviewTypeBehavioral, + } + isolatedDeleteIv, err := interviewRepo.CreateInterview(deleteIv) + require.NoError(t, err) + content := "Delete me" note := &models.InterviewNote{ - InterviewID: createdInterview.ID, - NoteType: models.NoteTypeReflection, + InterviewID: isolatedDeleteIv.ID, + NoteType: models.NoteTypeGeneral, Content: &content, } created, err := noteRepo.CreateInterviewNote(note)