package mysql import ( "context" "errors" "fmt" "strings" "testing" "time" "gorm.io/gorm" "wucher/internal/domain/auth" "wucher/internal/domain/contact" "wucher/internal/shared/pkg/appctx" "wucher/internal/shared/pkg/uuidv7" ) func TestContactDetailSelectSQL_IncludesProfileAttachmentID(t *testing.T) { sql := contactDetailSelectSQL() if !strings.Contains(sql, "users.profile_attachment_id AS profile_attachment_id") { t.Fatalf("expected detail select to include users.profile_attachment_id alias") } } func TestContactListSelectSQL_IncludesProfileAttachmentID(t *testing.T) { sql := contactListSelectSQL() if !strings.Contains(sql, "users.profile_attachment_id AS profile_attachment_id") { t.Fatalf("expected list select to include users.profile_attachment_id alias") } } func TestContactRepositoryUpdateContactClearSSOEmailUnlinksMicrosoftIdentity(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) adminRoleID := uuidv7.MustBytes() pilotRoleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: adminRoleID, Code: "admin", Name: "Admin"}).Error; err != nil { t.Fatalf("create admin role: %v", err) } if err := db.Create(&auth.Role{ID: pilotRoleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create pilot role: %v", err) } adminUser := &auth.User{ ID: uuidv7.MustBytes(), Email: "admin3@example.com", FirstName: "Admin", LastName: "User", Timezone: "UTC", RoleID: adminRoleID, IsActive: true, } if err := db.Create(adminUser).Error; err != nil { t.Fatalf("create admin user: %v", err) } ctx := appctx.WithUserID(context.Background(), adminUser.ID) sso := "a@microsoft.com" target := &auth.User{ ID: uuidv7.MustBytes(), Email: "target@example.com", SSOEmail: &sso, FirstName: "Target", LastName: "User", Timezone: "UTC", RoleID: pilotRoleID, IsActive: true, } if err := db.Create(target).Error; err != nil { t.Fatalf("create target user: %v", err) } if err := db.Create(&auth.UserIdentity{ ID: uuidv7.MustBytes(), UserID: target.ID, Provider: "microsoft", ProviderSubject: "subject-a", Email: "a@microsoft.com", }).Error; err != nil { t.Fatalf("create identity: %v", err) } empty := " " if err := repo.UpdateContact(ctx, contact.UpdateInput{ UserID: target.ID, User: contact.UserPatch{ SSOEmail: &empty, }, }); err != nil { t.Fatalf("update contact clear sso email: %v", err) } var refreshed auth.User if err := db.Where("id = ?", target.ID).Take(&refreshed).Error; err != nil { t.Fatalf("reload user: %v", err) } if refreshed.SSOEmail != nil { t.Fatalf("expected user sso_email NULL, got %v", *refreshed.SSOEmail) } var count int64 if err := db.Table("user_identities"). Where("user_id = ? AND provider = ?", target.ID, "microsoft"). Count(&count).Error; err != nil { t.Fatalf("count identities: %v", err) } if count != 0 { t.Fatalf("expected microsoft identity unlinked, got %d rows", count) } } func TestContactRepositoryDeleteContactRemovesUserAndProfiles(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create role: %v", err) } username := "pilot.delete" user := &auth.User{ ID: uuidv7.MustBytes(), Email: "pilot-delete@example.com", Username: &username, FirstName: "Pilot", LastName: "Delete", Timezone: "Asia/Jakarta", RoleID: roleID, IsActive: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := db.Create(&auth.PilotProfile{ UserID: user.ID, PilotCategory: auth.PilotCategoryRegular, ShortName: "PD", }).Error; err != nil { t.Fatalf("create pilot profile: %v", err) } if err := db.Create(&auth.DoctorProfile{ UserID: user.ID, ShortName: "Dr PD", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create extra profile: %v", err) } if err := repo.DeleteContact(context.Background(), user.ID); err != nil { t.Fatalf("delete contact: %v", err) } var userCount int64 if err := db.Table("users").Where("id = ?", user.ID).Count(&userCount).Error; err != nil { t.Fatalf("count users: %v", err) } if userCount != 0 { t.Fatalf("expected user deleted, got %d rows", userCount) } for _, table := range contactProfileTables { var count int64 if err := db.Table(table).Where("user_id = ?", user.ID).Count(&count).Error; err != nil { t.Fatalf("count %s: %v", table, err) } if count != 0 { t.Fatalf("expected %s deleted, got %d rows", table, count) } } } func TestContactRepositoryDeleteContactBlockedWhenUsedByBase(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create role: %v", err) } user := &auth.User{ ID: uuidv7.MustBytes(), Email: "pilot-base-ref@example.com", FirstName: "Pilot", LastName: "Ref", Timezone: "UTC", RoleID: roleID, IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := db.Exec(`CREATE TABLE IF NOT EXISTS base_contact_roles ( id BLOB PRIMARY KEY, base_id BLOB NOT NULL, contact_id BLOB NOT NULL, role_code TEXT NOT NULL )`).Error; err != nil { t.Fatalf("create base_contact_roles: %v", err) } repo := NewContactRepository(db) if err := db.Table("base_contact_roles").Create(map[string]any{ "id": uuidv7.MustBytes(), "base_id": uuidv7.MustBytes(), "contact_id": user.ID, "role_code": "responsible_flight_rescuer", }).Error; err != nil { t.Fatalf("seed base_contact_roles: %v", err) } err := repo.DeleteContact(context.Background(), user.ID) if err == nil { t.Fatalf("expected delete to be blocked") } if !strings.Contains(strings.ToLower(err.Error()), "bases") { t.Fatalf("expected bases dependency in error, got: %v", err) } } func TestContactRepositoryUpdateContactSameValuesDoesNotReturnNotFound(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create role: %v", err) } issuedAt := time.Date(2004, 10, 3, 0, 0, 0, 0, time.UTC) expiredAt := time.Date(2029, 3, 27, 0, 0, 0, 0, time.UTC) weight := 0.0 username := "pilot_test_2" user := &auth.User{ ID: uuidv7.MustBytes(), Email: "pilot2@test.com", Username: &username, FirstName: "a", LastName: "a", MobilePhone: "2", Timezone: "Asia/Jakarta", RoleID: roleID, IsActive: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := db.Create(&auth.PilotProfile{ UserID: user.ID, PilotCategory: auth.PilotCategoryRegular, ShortName: "a", LicenseNo: "a", LicenseIssuedAt: &issuedAt, LicenseExpiredAt: &expiredAt, TechnicianLicenseNo: "", HasIFRQualification: true, IsChiefPilot: false, IsDUL: true, TotalFlightMinutes: 2, ResponsiblePilotMinutes: 2, SecondPilotMinutes: 2, DoubleCommandMinutes: 2, FlightInstructorMinutes: 0, NightFlightMinutes: 2, IFRFlightMinutes: 2, Location: "string", Postcode: "2", StreetLine: "2", WeightKG: &weight, PhotoFileID: "a", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create pilot profile: %v", err) } email := "pilot2@test.com" firstName := "a" lastName := "a" mobilePhone := "2" timezone := "Asia/Jakarta" isActive := true pilotCategory := auth.PilotCategoryRegular shortName := "a" licenseNo := "a" technicianLicenseNo := "" hasIFRQualification := true isChiefPilot := false isDUL := true totalFlightMinutes := 2 responsiblePilotMinutes := 2 secondPilotMinutes := 2 doubleCommandMinutes := 2 flightInstructorMinutes := 0 nightFlightMinutes := 2 ifrFlightMinutes := 2 location := "string" postcode := "2" streetLine := "2" photoFileID := "a" err := repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: user.ID, User: contact.UserPatch{ Email: &email, Username: &username, FirstName: &firstName, LastName: &lastName, MobilePhone: &mobilePhone, Timezone: &timezone, IsActive: &isActive, }, Profile: contact.ProfilePatch{ PilotCategory: &pilotCategory, ShortName: &shortName, LicenseNo: &licenseNo, LicenseIssuedAt: &issuedAt, LicenseExpiredAt: &expiredAt, TechnicianLicenseNo: &technicianLicenseNo, HasIFRQualification: &hasIFRQualification, IsChiefPilot: &isChiefPilot, IsDUL: &isDUL, TotalFlightMinutes: &totalFlightMinutes, ResponsiblePilotMinutes: &responsiblePilotMinutes, SecondPilotMinutes: &secondPilotMinutes, DoubleCommandMinutes: &doubleCommandMinutes, FlightInstructorMinutes: &flightInstructorMinutes, NightFlightMinutes: &nightFlightMinutes, IFRFlightMinutes: &ifrFlightMinutes, Location: &location, Postcode: &postcode, StreetLine: &streetLine, WeightKG: &weight, PhotoFileID: &photoFileID, }, }) if err != nil { t.Fatalf("update contact with same values: %v", err) } var pilotProfileCount int64 if err := db.Table("pilot_profiles").Where("user_id = ?", user.ID).Count(&pilotProfileCount).Error; err != nil { t.Fatalf("count pilot profiles: %v", err) } if pilotProfileCount != 1 { t.Fatalf("expected single pilot profile row after update, got %d", pilotProfileCount) } got, err := repo.GetContactByID(context.Background(), user.ID) if err != nil { t.Fatalf("get contact after update: %v", err) } if got == nil || got.RoleCode != auth.RoleCodePilot { t.Fatalf("expected pilot contact after update, got %#v", got) } } func TestContactRepositoryUpdateContactClearsAirRescuerProfileStringFields(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeAirRescuer, Name: "Air Rescuer"}).Error; err != nil { t.Fatalf("create role: %v", err) } user := &auth.User{ ID: uuidv7.MustBytes(), Email: "air-rescuer@test.com", FirstName: "Air", LastName: "Rescuer", Timezone: "UTC", RoleID: roleID, IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := db.Create(&auth.AirRescuerProfile{ UserID: user.ID, ShortName: "CINDY", Location: "12", Postcode: "12", StreetLine: "12", Note: "12", ResponsibleFlightRescuer: false, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create air rescuer profile: %v", err) } empty := "" if err := repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: user.ID, Profile: contact.ProfilePatch{ ShortName: &empty, Location: &empty, Postcode: &empty, StreetLine: &empty, Note: &empty, }, }); err != nil { t.Fatalf("update contact clear profile strings: %v", err) } var profile auth.AirRescuerProfile if err := db.Where("user_id = ?", user.ID).Take(&profile).Error; err != nil { t.Fatalf("reload air rescuer profile: %v", err) } if profile.ShortName != "" || profile.Location != "" || profile.Postcode != "" || profile.StreetLine != "" || profile.Note != "" { t.Fatalf("expected cleared string fields, got %#v", profile) } } func TestContactRepositoryCreateContactRejectsDuplicateUsername(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create role: %v", err) } username := "pilot.duplicate" first, err := repo.CreateContact(context.Background(), contact.CreateInput{ RoleID: roleID, User: contact.UserPatch{ Email: strPtrContactRepo("first@example.com"), Username: &username, FirstName: strPtrContactRepo("First"), LastName: strPtrContactRepo("User"), }, }) if err != nil { t.Fatalf("create first contact: %v", err) } if len(first) != 16 { t.Fatalf("expected first user id, got len=%d", len(first)) } secondUsername := "pilot.duplicate" _, err = repo.CreateContact(context.Background(), contact.CreateInput{ RoleID: roleID, User: contact.UserPatch{ Email: strPtrContactRepo("second@example.com"), Username: &secondUsername, FirstName: strPtrContactRepo("Second"), LastName: strPtrContactRepo("User"), }, }) if !errors.Is(err, contact.ErrUsernameAlreadyExists) { t.Fatalf("expected duplicate username error, got %v", err) } } func TestContactRepositoryUpdateContactRejectsDuplicateUsername(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create role: %v", err) } firstUsername := "pilot.update.1" firstID, err := repo.CreateContact(context.Background(), contact.CreateInput{ RoleID: roleID, User: contact.UserPatch{ Email: strPtrContactRepo("first-update@example.com"), Username: &firstUsername, FirstName: strPtrContactRepo("First"), LastName: strPtrContactRepo("User"), }, }) if err != nil { t.Fatalf("create first contact: %v", err) } secondUsername := "pilot.update.2" secondID, err := repo.CreateContact(context.Background(), contact.CreateInput{ RoleID: roleID, User: contact.UserPatch{ Email: strPtrContactRepo("second-update@example.com"), Username: &secondUsername, FirstName: strPtrContactRepo("Second"), LastName: strPtrContactRepo("User"), }, }) if err != nil { t.Fatalf("create second contact: %v", err) } newUsername := firstUsername err = repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: secondID, User: contact.UserPatch{ Username: &newUsername, }, }) if !errors.Is(err, contact.ErrUsernameAlreadyExists) { t.Fatalf("expected duplicate username error on update, got %v", err) } got, err := repo.GetContactByID(context.Background(), firstID) if err != nil { t.Fatalf("get first contact: %v", err) } if got == nil || got.Username != firstUsername { t.Fatalf("unexpected first contact after duplicate update: %#v", got) } } func TestContactRepositoryListContactsDefaultSortByActiveAndSortKey(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil { t.Fatalf("create role: %v", err) } seedContactStaffUser(t, db, roleID, "Gamma", nil, true, "g-note") seedContactStaffUser(t, db, roleID, "Beta", intPtrContactRepo(0), true, "b-note") seedContactStaffUser(t, db, roleID, "Charlie", intPtrContactRepo(2), true, "c-note") seedContactStaffUser(t, db, roleID, "Alpha", intPtrContactRepo(1), true, "a-note") seedContactStaffUser(t, db, roleID, "Zulu", nil, false, "z-note") seedContactStaffUser(t, db, roleID, "Bravo", intPtrContactRepo(9), false, "br-note") rows, total, err := repo.ListContacts(context.Background(), contact.ListFilter{}) if err != nil { t.Fatalf("list contacts: %v", err) } if total != 6 || len(rows) != 6 { t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows)) } got := []string{rows[0].FirstName, rows[1].FirstName, rows[2].FirstName, rows[3].FirstName, rows[4].FirstName, rows[5].FirstName} want := []string{"Beta", "Alpha", "Charlie", "Gamma", "Bravo", "Zulu"} for i := range want { if got[i] != want[i] { t.Fatalf("unexpected order got=%v want=%v", got, want) } } } func TestContactRepositoryListContactsSearchByNote(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil { t.Fatalf("create role: %v", err) } seedContactStaffUser(t, db, roleID, "Alpha", nil, true, "dispatcher") seedContactStaffUser(t, db, roleID, "Bravo", nil, true, "maintenance") rows, total, err := repo.ListContacts(context.Background(), contact.ListFilter{Search: "dispatch"}) if err != nil { t.Fatalf("list contacts: %v", err) } if total != 1 || len(rows) != 1 { t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows)) } if rows[0].FirstName != "Alpha" || rows[0].Note != "dispatcher" { t.Fatalf("unexpected row %+v", rows[0]) } } func TestContactRepositoryListContactsIncludesPilotProfileFields(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create role: %v", err) } userID := uuidv7.MustBytes() username := "pilot_list_detail" issuedAt := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC) expiredAt := time.Date(2029, 4, 20, 0, 0, 0, 0, time.UTC) user := &auth.User{ ID: userID, Email: "pilot.list@example.com", Username: &username, FirstName: "Pilot", LastName: "List", Timezone: "UTC", RoleID: roleID, IsActive: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := db.Create(&auth.PilotProfile{ UserID: userID, PilotCategory: auth.PilotCategoryFreelance, LicenseIssuedAt: &issuedAt, LicenseExpiredAt: &expiredAt, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create pilot profile: %v", err) } rows, total, err := repo.ListContacts(context.Background(), contact.ListFilter{Role: auth.RoleCodePilot}) if err != nil { t.Fatalf("list contacts: %v", err) } if total != 1 || len(rows) != 1 { t.Fatalf("unexpected total/len total=%d len=%d", total, len(rows)) } if rows[0].PilotLicenseIssuedAt == nil || !rows[0].PilotLicenseIssuedAt.Equal(issuedAt) { t.Fatalf("expected pilot license_issued_at returned, got %#v", rows[0].PilotLicenseIssuedAt) } if rows[0].PilotLicenseExpiredAt == nil || !rows[0].PilotLicenseExpiredAt.Equal(expiredAt) { t.Fatalf("expected pilot license_expired_at returned, got %#v", rows[0].PilotLicenseExpiredAt) } if rows[0].PilotCategory != auth.PilotCategoryFreelance { t.Fatalf("expected pilot category returned, got %q", rows[0].PilotCategory) } } func TestContactRepositoryUpdateContactRoleAssignsRoleWhenCurrentRoleIsEmpty(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) staffRoleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: staffRoleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil { t.Fatalf("create role: %v", err) } userID := uuidv7.MustBytes() user := &auth.User{ ID: userID, Email: "no-role@example.com", FirstName: "No", LastName: "Role", Timezone: "UTC", IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := repo.UpdateContactRole(context.Background(), userID, auth.RoleCodeStaff); err != nil { t.Fatalf("update contact role: %v", err) } var refreshed auth.User if err := db.Where("id = ?", userID).Take(&refreshed).Error; err != nil { t.Fatalf("reload user: %v", err) } if len(refreshed.RoleID) != 16 || string(refreshed.RoleID) != string(staffRoleID) { t.Fatalf("expected role_id updated to staff, got %x", refreshed.RoleID) } roleIDs, err := listUserRoleIDsTx(db, userID) if err != nil { t.Fatalf("list user roles: %v", err) } if len(roleIDs) != 1 || string(roleIDs[0]) != string(staffRoleID) { t.Fatalf("expected user_roles to contain only staff role, got %d rows", len(roleIDs)) } } func TestContactRepositoryUpdateContact_IsAdminTruePersistsAdminRoleWhenUpdatingRoles(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) adminRoleID := uuidv7.MustBytes() pilotRoleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: adminRoleID, Code: "admin", Name: "Admin"}).Error; err != nil { t.Fatalf("create admin role: %v", err) } if err := db.Create(&auth.Role{ID: pilotRoleID, Code: auth.RoleCodePilot, Name: "Pilot"}).Error; err != nil { t.Fatalf("create pilot role: %v", err) } user := &auth.User{ ID: uuidv7.MustBytes(), Email: "admin-sync@example.com", FirstName: "Admin", LastName: "Sync", Timezone: "UTC", RoleID: pilotRoleID, IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := replaceUserRolesTx(context.Background(), db, user.ID, pilotRoleID, [][]byte{pilotRoleID}); err != nil { t.Fatalf("seed user roles: %v", err) } trueVal := true if err := repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: user.ID, UpdateRoles: true, RoleID: pilotRoleID, RoleIDs: [][]byte{pilotRoleID}, User: contact.UserPatch{ IsAdmin: &trueVal, }, }); err != nil { t.Fatalf("update contact: %v", err) } roleIDs, err := listUserRoleIDsTx(db, user.ID) if err != nil { t.Fatalf("list user roles: %v", err) } if len(roleIDs) != 2 { t.Fatalf("expected 2 roles (pilot + admin), got %d", len(roleIDs)) } foundAdmin := false for i := range roleIDs { if string(roleIDs[i]) == string(adminRoleID) { foundAdmin = true break } } if !foundAdmin { t.Fatalf("expected admin role assigned") } } func TestContactRepositoryUpdateContact_StaffCanBeAdmin(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) adminRoleID := uuidv7.MustBytes() staffRoleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: adminRoleID, Code: "admin", Name: "Admin"}).Error; err != nil { t.Fatalf("create admin role: %v", err) } if err := db.Create(&auth.Role{ID: staffRoleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil { t.Fatalf("create staff role: %v", err) } user := &auth.User{ ID: uuidv7.MustBytes(), Email: "staff-admin@example.com", FirstName: "Staff", LastName: "Admin", Timezone: "UTC", RoleID: staffRoleID, IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := replaceUserRolesTx(context.Background(), db, user.ID, staffRoleID, [][]byte{staffRoleID}); err != nil { t.Fatalf("seed user roles: %v", err) } trueVal := true if err := repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: user.ID, User: contact.UserPatch{ IsAdmin: &trueVal, }, }); err != nil { t.Fatalf("update contact: %v", err) } roleIDs, err := listUserRoleIDsTx(db, user.ID) if err != nil { t.Fatalf("list user roles: %v", err) } if len(roleIDs) != 2 { t.Fatalf("expected 2 roles (staff + admin), got %d", len(roleIDs)) } foundAdmin := false for i := range roleIDs { if string(roleIDs[i]) == string(adminRoleID) { foundAdmin = true break } } if !foundAdmin { t.Fatalf("expected admin role assigned") } } func TestContactRepositoryUpdateContactNilSortKeyDoesNotClearWithoutSetFlag(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil { t.Fatalf("create role: %v", err) } sortKey := 7 user := &auth.User{ ID: uuidv7.MustBytes(), Email: "sortkey-clear@example.com", FirstName: "Sort", LastName: "Key", Timezone: "UTC", RoleID: roleID, SortKey: &sortKey, IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } firstName := "Sorted" if err := repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: user.ID, User: contact.UserPatch{ FirstName: &firstName, SortKey: nil, SortKeySet: false, }, }); err != nil { t.Fatalf("update contact: %v", err) } var refreshed auth.User if err := db.Where("id = ?", user.ID).Take(&refreshed).Error; err != nil { t.Fatalf("reload user: %v", err) } if refreshed.SortKey == nil || *refreshed.SortKey != 7 { t.Fatalf("expected sortkey to remain 7, got %#v", refreshed.SortKey) } } func TestContactRepositoryUpdateContactNullSortKeyClearsValue(t *testing.T) { db := openDBTestSQLite(t) if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } repo := NewContactRepository(db) roleID := uuidv7.MustBytes() if err := db.Create(&auth.Role{ID: roleID, Code: auth.RoleCodeStaff, Name: "Staff"}).Error; err != nil { t.Fatalf("create role: %v", err) } sortKey := 7 user := &auth.User{ ID: uuidv7.MustBytes(), Email: "sortkey-null@example.com", FirstName: "Sort", LastName: "Key", Timezone: "UTC", RoleID: roleID, SortKey: &sortKey, IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := repo.UpdateContact(context.Background(), contact.UpdateInput{ UserID: user.ID, User: contact.UserPatch{ SortKey: nil, SortKeySet: true, }, }); err != nil { t.Fatalf("update contact: %v", err) } var refreshed auth.User if err := db.Where("id = ?", user.ID).Take(&refreshed).Error; err != nil { t.Fatalf("reload user: %v", err) } if refreshed.SortKey != nil { t.Fatalf("expected sortkey NULL, got %d", *refreshed.SortKey) } } func seedContactStaffUser(t *testing.T, db *gorm.DB, roleID []byte, firstName string, sortKey *int, isActive bool, note string) []byte { t.Helper() userID := uuidv7.MustBytes() username := fmt.Sprintf("%s_user_%d", firstName, time.Now().UnixNano()) user := &auth.User{ ID: userID, Email: fmt.Sprintf("%s@example.com", username), Username: &username, FirstName: firstName, LastName: "Contact", Timezone: "UTC", RoleID: roleID, SortKey: sortKey, IsActive: isActive, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := db.Create(user).Error; err != nil { t.Fatalf("create user: %v", err) } if err := db.Model(&auth.User{}).Where("id = ?", userID).UpdateColumn("is_active", isActive).Error; err != nil { t.Fatalf("sync user is_active: %v", err) } if err := db.Create(&auth.StaffProfile{ UserID: userID, ShortName: firstName, Note: note, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create staff profile: %v", err) } return userID } func intPtrContactRepo(v int) *int { return &v } func strPtrContactRepo(v string) *string { return &v }