package service import ( "context" "errors" "testing" "wucher/internal/domain/contact" "wucher/internal/shared/pkg/uuidv7" ) type mockContactRepo struct { listItems []contact.ContactListItem listTotal int64 listErr error lastFilter contact.ListFilter getItem *contact.ContactListItem getErr error lastGetID []byte isActorAdmin bool isActorAdminErr error resolveRoleCode string resolveRoleErr error lastResolveID []byte createID []byte createErr error lastCreate contact.CreateInput updateErr error lastUpdate contact.UpdateInput deleteErr error lastDeleteID []byte updateStatusErr error lastStatusID []byte lastStatusIsActive bool updateRoleErr error lastRoleID []byte lastRoleCode string updatePilotErr error lastPilotID []byte lastCategory string updateLicenseErr error lastLicenseID []byte lastLicenseNo *string updateChiefFlagsErr error lastChiefID []byte lastChiefPatch contact.ProfilePatch updateResponsibleErr error lastResponsibleID []byte lastResponsibleValue bool bulkErr error bulkCount int64 lastBulkIDs [][]byte lastBulkActive bool } func (m *mockContactRepo) ListContacts(_ context.Context, filter contact.ListFilter) ([]contact.ContactListItem, int64, error) { m.lastFilter = filter if m.listErr != nil { return nil, 0, m.listErr } return m.listItems, m.listTotal, nil } func (m *mockContactRepo) GetContactByID(_ context.Context, userID []byte) (*contact.ContactListItem, error) { m.lastGetID = append([]byte(nil), userID...) if m.getErr != nil { return nil, m.getErr } return m.getItem, nil } func (m *mockContactRepo) IsActorAdmin(_ context.Context) (bool, error) { if m.isActorAdminErr != nil { return false, m.isActorAdminErr } return m.isActorAdmin, nil } func (m *mockContactRepo) ResolveRoleCodeByID(_ context.Context, roleID []byte) (string, error) { m.lastResolveID = append([]byte(nil), roleID...) if m.resolveRoleErr != nil { return "", m.resolveRoleErr } return m.resolveRoleCode, nil } func (m *mockContactRepo) CreateContact(_ context.Context, in contact.CreateInput) ([]byte, error) { m.lastCreate = in if m.createErr != nil { return nil, m.createErr } return m.createID, nil } func (m *mockContactRepo) UpdateContact(_ context.Context, in contact.UpdateInput) error { m.lastUpdate = in return m.updateErr } func (m *mockContactRepo) DeleteContact(_ context.Context, userID []byte) error { m.lastDeleteID = append([]byte(nil), userID...) return m.deleteErr } func (m *mockContactRepo) UpdateContactStatus(_ context.Context, userID []byte, isActive bool) error { m.lastStatusID = append([]byte(nil), userID...) m.lastStatusIsActive = isActive return m.updateStatusErr } func (m *mockContactRepo) UpdateContactRole(_ context.Context, userID []byte, roleCode string) error { m.lastRoleID = append([]byte(nil), userID...) m.lastRoleCode = roleCode return m.updateRoleErr } func (m *mockContactRepo) UpdatePilotCategory(_ context.Context, userID []byte, category string) error { m.lastPilotID = append([]byte(nil), userID...) m.lastCategory = category return m.updatePilotErr } func (m *mockContactRepo) UpdateLicense(_ context.Context, userID []byte, licenseNo *string) error { m.lastLicenseID = append([]byte(nil), userID...) m.lastLicenseNo = licenseNo return m.updateLicenseErr } func (m *mockContactRepo) UpdateChiefFlags(_ context.Context, userID []byte, patch contact.ProfilePatch) error { m.lastChiefID = append([]byte(nil), userID...) m.lastChiefPatch = patch return m.updateChiefFlagsErr } func (m *mockContactRepo) UpdateResponsibleComplaints(_ context.Context, userID []byte, value bool) error { m.lastResponsibleID = append([]byte(nil), userID...) m.lastResponsibleValue = value return m.updateResponsibleErr } func (m *mockContactRepo) BulkUpdateStatus(_ context.Context, userIDs [][]byte, isActive bool) (int64, error) { m.lastBulkIDs = append([][]byte(nil), userIDs...) m.lastBulkActive = isActive if m.bulkErr != nil { return 0, m.bulkErr } return m.bulkCount, nil } type mockPINVerifier struct { err error lastUserID []byte lastAction string lastToken string } func (m *mockPINVerifier) ConsumeSecurityPINActionToken(_ context.Context, userID []byte, action, token string) error { m.lastUserID = append([]byte(nil), userID...) m.lastAction = action m.lastToken = token return m.err } func TestNewContactService(t *testing.T) { repo := &mockContactRepo{} verifier := &mockPINVerifier{} svc := NewContactService(repo, verifier) if svc == nil || svc.repo != repo || svc.pinVerifier != verifier { t.Fatalf("expected service to hold dependencies") } } func TestContactServiceRepoNotConfigured(t *testing.T) { svc := NewContactService(nil, nil) ctx := context.Background() userID := uuidv7.MustBytes() if _, _, err := svc.ListContacts(ctx, contact.ListFilter{}); err == nil { t.Fatalf("expected list error") } if _, err := svc.GetContactByID(ctx, userID); err == nil { t.Fatalf("expected get error") } if _, err := svc.ResolveRoleCodeByID(ctx, userID); err == nil { t.Fatalf("expected resolve role error") } if _, err := svc.CreateContact(ctx, contact.CreateInput{}); err == nil { t.Fatalf("expected create error") } if err := svc.UpdateContact(ctx, contact.UpdateInput{}); err == nil { t.Fatalf("expected update error") } if err := svc.DeleteContact(ctx, userID); err == nil { t.Fatalf("expected delete error") } if err := svc.UpdateContactStatus(ctx, userID, true); err == nil { t.Fatalf("expected status error") } if err := svc.UpdateContactRole(ctx, userID, "pilot"); err == nil { t.Fatalf("expected role error") } if err := svc.UpdatePilotCategory(ctx, userID, "regular"); err == nil { t.Fatalf("expected pilot category error") } if err := svc.UpdateLicense(ctx, userID, nil); err == nil { t.Fatalf("expected license error") } if err := svc.UpdateChiefFlags(ctx, userID, contact.ProfilePatch{}); err == nil { t.Fatalf("expected chief flags error") } if err := svc.UpdateResponsibleComplaints(ctx, userID, true); err == nil { t.Fatalf("expected responsible complaints error") } if _, err := svc.BulkUpdateStatus(ctx, [][]byte{userID}, true); err == nil { t.Fatalf("expected bulk update error") } } func TestContactServiceDelegatesToRepo(t *testing.T) { ctx := context.Background() userID := uuidv7.MustBytes() roleID := uuidv7.MustBytes() licenseNo := "LIC" shortName := "PJ" repoErr := errors.New("repo failed") t.Run("list success and error", func(t *testing.T) { repo := &mockContactRepo{ listItems: []contact.ContactListItem{{UserID: userID, Email: "user@example.com"}}, listTotal: 1, } svc := NewContactService(repo, nil) filter := contact.ListFilter{Role: "pilot", Sort: "sortkey", Limit: 10, Offset: 5} items, total, err := svc.ListContacts(ctx, filter) if err != nil || total != 1 || len(items) != 1 { t.Fatalf("unexpected list result: total=%d len=%d err=%v", total, len(items), err) } if repo.lastFilter.Role != "pilot" || repo.lastFilter.Limit != 10 || repo.lastFilter.Offset != 5 { t.Fatalf("expected filter forwarded, got %#v", repo.lastFilter) } if repo.lastFilter.Sort != "users.is_active DESC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN users.sortkey END ASC, users.first_name ASC" { t.Fatalf("unexpected normalized sort, got %q", repo.lastFilter.Sort) } repo.listErr = repoErr if _, _, err := svc.ListContacts(ctx, filter); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("get success and error", func(t *testing.T) { repo := &mockContactRepo{getItem: &contact.ContactListItem{UserID: userID, Email: "user@example.com"}} svc := NewContactService(repo, nil) item, err := svc.GetContactByID(ctx, userID) if err != nil || item == nil || string(repo.lastGetID) != string(userID) { t.Fatalf("unexpected get result: item=%v err=%v", item, err) } repo.getErr = repoErr if _, err := svc.GetContactByID(ctx, userID); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("resolve role success and error", func(t *testing.T) { repo := &mockContactRepo{resolveRoleCode: "pilot"} svc := NewContactService(repo, nil) roleCode, err := svc.ResolveRoleCodeByID(ctx, roleID) if err != nil || roleCode != "pilot" || string(repo.lastResolveID) != string(roleID) { t.Fatalf("unexpected resolve role result: role=%q err=%v", roleCode, err) } repo.resolveRoleErr = repoErr if _, err := svc.ResolveRoleCodeByID(ctx, roleID); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("create success and error", func(t *testing.T) { repo := &mockContactRepo{createID: userID} svc := NewContactService(repo, nil) in := contact.CreateInput{ RoleID: roleID, User: contact.UserPatch{Email: &licenseNo}, Profile: contact.ProfilePatch{ShortName: &shortName}, } gotID, err := svc.CreateContact(ctx, in) if err != nil || string(gotID) != string(userID) { t.Fatalf("unexpected create result: id=%v err=%v", gotID, err) } if string(repo.lastCreate.RoleID) != string(roleID) || repo.lastCreate.Profile.ShortName == nil || *repo.lastCreate.Profile.ShortName != shortName { t.Fatalf("expected create input forwarded") } repo.createErr = repoErr if _, err := svc.CreateContact(ctx, in); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("update success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) in := contact.UpdateInput{UserID: userID, Profile: contact.ProfilePatch{ShortName: &shortName}} if err := svc.UpdateContact(ctx, in); err != nil { t.Fatalf("unexpected update error: %v", err) } if string(repo.lastUpdate.UserID) != string(userID) || repo.lastUpdate.Profile.ShortName == nil || *repo.lastUpdate.Profile.ShortName != shortName { t.Fatalf("expected update input forwarded") } repo.updateErr = repoErr if err := svc.UpdateContact(ctx, in); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("delete success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) if err := svc.DeleteContact(ctx, userID); err != nil { t.Fatalf("unexpected delete error: %v", err) } if string(repo.lastDeleteID) != string(userID) { t.Fatalf("expected delete id forwarded") } repo.deleteErr = repoErr if err := svc.DeleteContact(ctx, userID); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("status success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) if err := svc.UpdateContactStatus(ctx, userID, true); err != nil { t.Fatalf("unexpected status error: %v", err) } if string(repo.lastStatusID) != string(userID) || !repo.lastStatusIsActive { t.Fatalf("expected status args forwarded") } repo.updateStatusErr = repoErr if err := svc.UpdateContactStatus(ctx, userID, true); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("role success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) if err := svc.UpdateContactRole(ctx, userID, "pilot"); err != nil { t.Fatalf("unexpected role error: %v", err) } if string(repo.lastRoleID) != string(userID) || repo.lastRoleCode != "pilot" { t.Fatalf("expected role args forwarded") } repo.updateRoleErr = repoErr if err := svc.UpdateContactRole(ctx, userID, "pilot"); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("pilot category success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) if err := svc.UpdatePilotCategory(ctx, userID, "regular"); err != nil { t.Fatalf("unexpected pilot category error: %v", err) } if string(repo.lastPilotID) != string(userID) || repo.lastCategory != "regular" { t.Fatalf("expected pilot category args forwarded") } repo.updatePilotErr = repoErr if err := svc.UpdatePilotCategory(ctx, userID, "regular"); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("license success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) if err := svc.UpdateLicense(ctx, userID, &licenseNo); err != nil { t.Fatalf("unexpected license error: %v", err) } if string(repo.lastLicenseID) != string(userID) || repo.lastLicenseNo == nil || *repo.lastLicenseNo != licenseNo { t.Fatalf("expected license args forwarded") } repo.updateLicenseErr = repoErr if err := svc.UpdateLicense(ctx, userID, &licenseNo); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("chief flags success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) patch := contact.ProfilePatch{ShortName: &shortName} if err := svc.UpdateChiefFlags(ctx, userID, patch); err != nil { t.Fatalf("unexpected chief flags error: %v", err) } if string(repo.lastChiefID) != string(userID) || repo.lastChiefPatch.ShortName == nil || *repo.lastChiefPatch.ShortName != shortName { t.Fatalf("expected chief flags args forwarded") } repo.updateChiefFlagsErr = repoErr if err := svc.UpdateChiefFlags(ctx, userID, patch); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("responsible complaints success and error", func(t *testing.T) { repo := &mockContactRepo{} svc := NewContactService(repo, nil) if err := svc.UpdateResponsibleComplaints(ctx, userID, true); err != nil { t.Fatalf("unexpected responsible complaints error: %v", err) } if string(repo.lastResponsibleID) != string(userID) || !repo.lastResponsibleValue { t.Fatalf("expected responsible complaints args forwarded") } repo.updateResponsibleErr = repoErr if err := svc.UpdateResponsibleComplaints(ctx, userID, true); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) t.Run("bulk update success and error", func(t *testing.T) { repo := &mockContactRepo{bulkCount: 2} svc := NewContactService(repo, nil) ids := [][]byte{userID, uuidv7.MustBytes()} count, err := svc.BulkUpdateStatus(ctx, ids, true) if err != nil || count != 2 { t.Fatalf("unexpected bulk update result: count=%d err=%v", count, err) } if len(repo.lastBulkIDs) != 2 || !repo.lastBulkActive { t.Fatalf("expected bulk update args forwarded") } repo.bulkErr = repoErr if _, err := svc.BulkUpdateStatus(ctx, ids, true); !errors.Is(err, repoErr) { t.Fatalf("expected repo error, got %v", err) } }) } func TestNormalizeContactSort(t *testing.T) { cases := map[string]string{ "sortkey": "users.is_active DESC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN users.sortkey END ASC, users.first_name ASC", "-sortkey": "users.is_active DESC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN users.sortkey END DESC, users.first_name ASC", "note": "COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') ASC", "-note": "COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') DESC", "is_active": "users.is_active ASC", "-is_active": "users.is_active DESC", "name": "users.first_name ASC", "email": "users.email ASC", "unknown": "", "": "", } for in, want := range cases { if got := normalizeContactSort(in); got != want { t.Fatalf("normalizeContactSort(%q) = %q, want %q", in, got, want) } } } func TestContactServiceCheckSensitiveAction(t *testing.T) { ctx := context.Background() validUserID := uuidv7.MustBytes() verifierErr := errors.New("pin verify failed") svc := NewContactService(&mockContactRepo{}, nil) if err := svc.CheckSensitiveAction(ctx, []byte("short"), "token", "action"); !errors.Is(err, ErrInvalidPINAction) { t.Fatalf("expected invalid pin action for invalid user id, got %v", err) } if err := svc.CheckSensitiveAction(ctx, validUserID, "token", "action"); err == nil || err.Error() != "pin verifier not configured" { t.Fatalf("expected pin verifier not configured error, got %v", err) } verifier := &mockPINVerifier{} svc = NewContactService(&mockContactRepo{}, verifier) if err := svc.CheckSensitiveAction(ctx, validUserID, " ", "action"); !errors.Is(err, ErrInvalidPINAction) { t.Fatalf("expected invalid pin action for empty token, got %v", err) } if err := svc.CheckSensitiveAction(ctx, validUserID, "token", " "); !errors.Is(err, ErrInvalidPINAction) { t.Fatalf("expected invalid pin action for empty action, got %v", err) } verifier.err = verifierErr if err := svc.CheckSensitiveAction(ctx, validUserID, " token ", " action "); !errors.Is(err, verifierErr) { t.Fatalf("expected verifier error, got %v", err) } if string(verifier.lastUserID) != string(validUserID) || verifier.lastAction != "action" || verifier.lastToken != "token" { t.Fatalf("expected trimmed action/token forwarded") } verifier.err = nil if err := svc.CheckSensitiveAction(ctx, validUserID, " token ", " action "); err != nil { t.Fatalf("unexpected verifier error: %v", err) } }