package handlers import ( "bytes" "context" "encoding/hex" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "wucher/internal/domain/contact" filemanager "wucher/internal/domain/file_manager" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/transport/http/dto" ) type stubContactService struct { listItems []contact.ContactListItem listTotal int64 listErr error lastFilter contact.ListFilter getItem *contact.ContactListItem getErr error resolveRoleCode string resolveRoleCodeByID map[string]string resolveRoleErr error lastResolveID []byte createUserID []byte createErr error lastCreate contact.CreateInput updateErr error lastUpdate contact.UpdateInput deleteErr error lastDeleteID []byte updateStatusErr error lastUpdateStatusID []byte lastIsActive bool updateRoleErr error lastUpdateRoleID []byte lastRoleCode string updatePilotCategoryErr error lastUpdatePilotID []byte lastPilotCategory string updateLicenseErr error lastUpdateLicenseID []byte lastLicenseNo *string updateChiefFlagsErr error lastUpdateChiefFlagsID []byte lastChiefFlags contact.ProfilePatch updateResponsibleErr error lastUpdateResponsibleID []byte lastResponsibleComplaints bool bulkUpdateErr error bulkUpdateCount int64 lastBulkIDs [][]byte lastBulkIsActive bool checkSensitiveActionCalled bool isActorAdmin bool isActorAdminErr error } func (s *stubContactService) ListContacts(_ context.Context, filter contact.ListFilter) ([]contact.ContactListItem, int64, error) { s.lastFilter = filter if s.listErr != nil { return nil, 0, s.listErr } return s.listItems, s.listTotal, nil } func (s *stubContactService) GetContactByID(_ context.Context, userID []byte) (*contact.ContactListItem, error) { if s.getErr != nil { return nil, s.getErr } if s.getItem != nil && len(s.getItem.UserID) == 0 { copyItem := *s.getItem copyItem.UserID = append([]byte(nil), userID...) return ©Item, nil } return s.getItem, nil } func (s *stubContactService) IsActorAdmin(_ context.Context) (bool, error) { if s.isActorAdminErr != nil { return false, s.isActorAdminErr } return s.isActorAdmin, nil } func (s *stubContactService) ResolveRoleCodeByID(_ context.Context, roleID []byte) (string, error) { s.lastResolveID = append([]byte(nil), roleID...) if s.resolveRoleErr != nil { return "", s.resolveRoleErr } if s.resolveRoleCodeByID != nil { if code, ok := s.resolveRoleCodeByID[string(roleID)]; ok { return code, nil } } return s.resolveRoleCode, nil } func (s *stubContactService) CreateContact(_ context.Context, in contact.CreateInput) ([]byte, error) { s.lastCreate = in if s.createErr != nil { return nil, s.createErr } if len(s.createUserID) == 0 { s.createUserID = uuidv7.MustBytes() } return s.createUserID, nil } func (s *stubContactService) UpdateContact(_ context.Context, in contact.UpdateInput) error { s.lastUpdate = in return s.updateErr } func (s *stubContactService) DeleteContact(_ context.Context, userID []byte) error { s.lastDeleteID = append([]byte(nil), userID...) return s.deleteErr } func (s *stubContactService) UpdateContactStatus(_ context.Context, userID []byte, isActive bool) error { s.lastUpdateStatusID = append([]byte(nil), userID...) s.lastIsActive = isActive return s.updateStatusErr } func (s *stubContactService) UpdateContactRole(_ context.Context, userID []byte, roleCode string) error { s.lastUpdateRoleID = append([]byte(nil), userID...) s.lastRoleCode = roleCode return s.updateRoleErr } func (s *stubContactService) UpdatePilotCategory(_ context.Context, userID []byte, category string) error { s.lastUpdatePilotID = append([]byte(nil), userID...) s.lastPilotCategory = category return s.updatePilotCategoryErr } func (s *stubContactService) UpdateLicense(_ context.Context, userID []byte, licenseNo *string) error { s.lastUpdateLicenseID = append([]byte(nil), userID...) s.lastLicenseNo = licenseNo return s.updateLicenseErr } func (s *stubContactService) UpdateChiefFlags(_ context.Context, userID []byte, patch contact.ProfilePatch) error { s.lastUpdateChiefFlagsID = append([]byte(nil), userID...) s.lastChiefFlags = patch return s.updateChiefFlagsErr } func (s *stubContactService) UpdateResponsibleComplaints(_ context.Context, userID []byte, value bool) error { s.lastUpdateResponsibleID = append([]byte(nil), userID...) s.lastResponsibleComplaints = value return s.updateResponsibleErr } func (s *stubContactService) BulkUpdateStatus(_ context.Context, userIDs [][]byte, isActive bool) (int64, error) { s.lastBulkIDs = append([][]byte(nil), userIDs...) s.lastBulkIsActive = isActive if s.bulkUpdateErr != nil { return 0, s.bulkUpdateErr } return s.bulkUpdateCount, nil } func (s *stubContactService) CheckSensitiveAction(_ context.Context, _ []byte, _ string, _ string) error { s.checkSensitiveActionCalled = true return nil } type stubContactInviter struct { err error lastUserID []byte } func (s *stubContactInviter) SendInviteSetPasswordForUser(_ context.Context, userID []byte) error { s.lastUserID = append([]byte(nil), userID...) return s.err } func newContactTestApp(h *ContactHandler) *fiber.App { app := fiber.New() app.Post("/contacts/create", h.Create) app.Get("/contacts", h.List) app.Get("/contacts/dt", h.ListDatatable) app.Get("/contacts/:user_id", h.Get) app.Patch("/contacts/:user_id", h.Update) app.Delete("/contacts/:user_id", h.Delete) app.Patch("/contacts/:user_id/status", h.UpdateContactStatus) app.Patch("/contacts/:user_id/role", h.UpdateContactRole) app.Patch("/contacts/:user_id/pilot-category", h.UpdatePilotCategory) app.Patch("/contacts/:user_id/license", h.UpdateLicense) app.Patch("/contacts/:user_id/chief-flags", h.UpdateChiefFlags) app.Post("/contacts/bulk-update", h.BulkUpdate) return app } func doJSONRequestContact(t *testing.T, app *fiber.App, method, path string, body []byte) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(method, path, bytes.NewReader(body)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } respBody, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return resp, respBody } func mustUUIDStringContact(t *testing.T, id []byte) string { t.Helper() s, err := uuidv7.BytesToString(id) if err != nil { t.Fatalf("uuid to string: %v", err) } return s } func mustRoleRawContact(t *testing.T, roleID []byte, roleName, roleCode string) string { t.Helper() return strings.ToLower(hex.EncodeToString(roleID)) + "|" + strings.ToLower(hex.EncodeToString([]byte(roleName))) + "|" + roleCode } func strPtrContact(v string) *string { return &v } func boolPtrContact(v bool) *bool { return &v } func intPtrContact(v int) *int { return &v } func floatPtrContact(v float64) *float64 { return &v } func TestNewContactHandler(t *testing.T) { h := NewContactHandler(&stubContactService{}, &stubContactInviter{}) if h == nil || h.validate == nil { t.Fatalf("expected handler and validator initialized") } h = h.WithFileManagerService(nil).WithFileStorage(nil) if h == nil { t.Fatalf("expected chain methods return handler") } } func TestContactHandlerCreate(t *testing.T) { t.Run("invalid json", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", []byte(`{}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid role id", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) body := []byte(`{"data":{"type":"contact_create","attributes":{"role_id":"bad","email":"user@example.com"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid create type", func(t *testing.T) { svc := &stubContactService{resolveRoleCode: "doctor"} app := newContactTestApp(NewContactHandler(svc, nil)) roleID := mustUUIDStringContact(t, uuidv7.MustBytes()) body := []byte(`{"data":{"type":"contact_pilot_create","attributes":{"role_id":"` + roleID + `","email":"user@example.com"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("mismatched profile payload is rejected", func(t *testing.T) { svc := &stubContactService{resolveRoleCode: "pilot"} app := newContactTestApp(NewContactHandler(svc, nil)) roleID := mustUUIDStringContact(t, uuidv7.MustBytes()) body := []byte(`{"data":{"type":"contact_create","attributes":{"role_id":"` + roleID + `","email":"user@example.com","doctor":{"short_name":"Dr"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("create error", func(t *testing.T) { svc := &stubContactService{resolveRoleCode: "pilot", createErr: errors.New("create failed")} app := newContactTestApp(NewContactHandler(svc, nil)) roleID := mustUUIDStringContact(t, uuidv7.MustBytes()) body := []byte(`{"data":{"type":"contact_create","attributes":{"role_id":"` + roleID + `","email":"user@example.com","username":"john","pilot":{"pilot_category":"regular"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success with invite and fetched item", func(t *testing.T) { roleID := mustUUIDStringContact(t, uuidv7.MustBytes()) userID := uuidv7.MustBytes() svc := &stubContactService{ resolveRoleCode: "pilot", createUserID: userID, getItem: &contact.ContactListItem{ RoleCode: "pilot", RoleName: "Pilot", PilotCategory: "regular", Username: " pilot.user ", Email: "pilot@example.com", FirstName: "John", LastName: "Doe", IsActive: true, PilotShortName: strPtrContact("PJ"), }, } inviter := &stubContactInviter{} app := newContactTestApp(NewContactHandler(svc, inviter)) body := []byte(`{"data":{"type":"contact_create","attributes":{"role_id":"` + roleID + `","email":"pilot@example.com","username":"pilot.user","first_name":" John ","last_name":" Doe ","mobile_phone":" 081 ","timezone":" Asia/Jakarta ","is_active":true,"pilot":{"pilot_category":"regular","short_name":" PJ ","license_no":" LIC-1 "}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if svc.lastCreate.User.Email == nil || *svc.lastCreate.User.Email != "pilot@example.com" { t.Fatalf("expected trimmed email, got %#v", svc.lastCreate.User.Email) } if svc.lastCreate.Profile.PilotCategory == nil || *svc.lastCreate.Profile.PilotCategory != "regular" { t.Fatalf("expected pilot category patched") } if string(inviter.lastUserID) != string(userID) { t.Fatalf("expected inviter called with created user id") } }) t.Run("success when invite fails and refetch nil", func(t *testing.T) { roleID := mustUUIDStringContact(t, uuidv7.MustBytes()) userID := uuidv7.MustBytes() svc := &stubContactService{resolveRoleCode: "staff", createUserID: userID} inviter := &stubContactInviter{err: errors.New("ses down")} app := newContactTestApp(NewContactHandler(svc, inviter)) body := []byte(`{"data":{"type":"contact_create","attributes":{"role_id":"` + roleID + `","email":"user@example.com","staff":{"short_name":"Dina"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } }) t.Run("get after create error", func(t *testing.T) { roleID := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{resolveRoleCode: "staff", createUserID: uuidv7.MustBytes(), getErr: errors.New("lookup failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_create","attributes":{"role_id":"` + roleID + `","email":"user@example.com","staff":{"short_name":"Dina"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/create", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestContactHelperFunctions(t *testing.T) { t.Run("contactBaseRoles and contactDULBases", func(t *testing.T) { baseID := uuidv7.MustBytes() baseHex := strings.ToLower(hex.EncodeToString(baseID)) nameHex := strings.ToLower(hex.EncodeToString([]byte("Halim Base"))) catHex := strings.ToLower(hex.EncodeToString([]byte("hems"))) raw := baseHex + "|" + nameHex + "|dul|" + catHex + "," + baseHex + "|" + nameHex + "|pilot|" + catHex roles := contactBaseRoles(&raw) if len(roles) != 2 { t.Fatalf("expected 2 base roles, got %d", len(roles)) } dulBases := contactDULBases(&raw) if len(dulBases) != 1 { t.Fatalf("expected only dul role base, got %d", len(dulBases)) } if dulBases[0]["category"] != "hems" { t.Fatalf("expected dul base category hems") } invalid := "invalid" if got := contactBaseRoles(&invalid); got != nil { t.Fatalf("invalid raw should return nil") } if got := contactDULBases(&invalid); got != nil { t.Fatalf("invalid raw should return nil dul bases") } }) t.Run("contactRoles", func(t *testing.T) { roleID := uuidv7.MustBytes() roleRaw := strings.ToLower(hex.EncodeToString(roleID)) + "|" + strings.ToLower(hex.EncodeToString([]byte("Pilot"))) + "|pilot" out := contactRoles(&roleRaw) if len(out) != 1 || out[0]["role_code"] != "pilot" { t.Fatalf("expected pilot role parsed") } blank := " " if got := contactRoles(&blank); len(got) != 0 { t.Fatalf("blank raw should return empty slice") } bad := "abc" if got := contactRoles(&bad); len(got) != 0 { t.Fatalf("bad raw should return empty slice") } }) t.Run("applyContactFoto no attachment id", func(t *testing.T) { h := NewContactHandler(&stubContactService{}, nil) attrs := map[string]any{} h.applyContactFoto(context.Background(), &contact.ContactListItem{}, attrs) if _, ok := attrs["foto"]; ok { t.Fatalf("foto should not be set without attachment id") } }) t.Run("resolveContactProfileAttachmentID and applyContactFoto success path", func(t *testing.T) { raw := mustUUIDStringContact(t, uuidv7.MustBytes()) profileID := uuidv7.MustBytes() thumb := "thumb-key" h := NewContactHandler(&stubContactService{}, nil). WithFileManagerService(&fileManagerAttachmentHandlerServiceMock{ createAttachmentFn: func(context.Context, filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { return &filemanager.Attachment{ID: profileID}, nil }, getAttachmentFn: func(context.Context, []byte) (*filemanager.Attachment, error) { return &filemanager.Attachment{ ID: profileID, File: &filemanager.File{ ObjectKey: "photo-key", SizeBytes: 100, ThumbnailObjectKey: &thumb, ThumbnailSizeBytes: 10, }, }, nil }, }). WithFileStorage(&basePresignStorageMock{url: "https://example.com/signed"}) app := fiber.New() app.Get("/x", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) id, handled := h.resolveContactProfileAttachmentID(c, uuidv7.MustBytes(), &raw) if handled || len(id) != 16 { t.Fatalf("expected resolved attachment id") } return nil }) resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/x", nil)) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("unexpected resolveContactProfileAttachmentID result") } attrs := map[string]any{} h.applyContactFoto(context.Background(), &contact.ContactListItem{ProfileAttachmentID: profileID}, attrs) foto, ok := attrs["foto"].(map[string]any) if !ok || strings.TrimSpace(foto["download_url"].(string)) == "" || strings.TrimSpace(foto["thumbnail_download_url"].(string)) == "" { t.Fatalf("expected foto urls to be set") } }) t.Run("resolveContactProfileAttachmentID invalid uuid", func(t *testing.T) { raw := "bad" h := NewContactHandler(&stubContactService{}, nil).WithFileManagerService(&fileManagerAttachmentHandlerServiceMock{}) app := fiber.New() app.Get("/x", func(c *fiber.Ctx) error { _, handled := h.resolveContactProfileAttachmentID(c, uuidv7.MustBytes(), &raw) if !handled { t.Fatalf("expected handled invalid uuid") } return nil }) resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/x", nil)) if err != nil || resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422 for invalid uuid") } }) t.Run("ensureContactProfileAttachmentSet error when missing profile id", func(t *testing.T) { svc := &stubContactService{getItem: &contact.ContactListItem{UserID: uuidv7.MustBytes()}} h := NewContactHandler(svc, nil) if err := h.ensureContactProfileAttachmentSet(context.Background(), uuidv7.MustBytes()); err == nil { t.Fatalf("expected error when profile attachment id is missing") } }) t.Run("inferContactCreateRoleCodes", func(t *testing.T) { codes := inferContactCreateRoleCodes(dto.ContactCreateAttributes{ Pilot: &dto.PilotProfileAttributes{}, Staff: &dto.StaffProfileAttributes{}, }) if len(codes) != 2 { t.Fatalf("expected inferred role codes from payloads, got %d", len(codes)) } }) t.Run("validatePilotDULBasePayload", func(t *testing.T) { errs := validatePilotDULBasePayload(&dto.PilotProfileAttributes{ DULBaseIDs: []string{"bad-uuid"}, }) if len(errs) == 0 { t.Fatalf("expected validation error for invalid dul_base_ids") } errs = validatePilotDULBasePayload(&dto.PilotProfileAttributes{ DULBase: []dto.DULBase{{ID: "bad-uuid"}}, }) if len(errs) == 0 { t.Fatalf("expected validation error for invalid dul_base") } errs = validatePilotDULBasePayload(&dto.PilotProfileAttributes{ DULBaseIDs: []string{mustUUIDStringContact(t, uuidv7.MustBytes())}, DULBase: []dto.DULBase{{ID: mustUUIDStringContact(t, uuidv7.MustBytes())}}, }) if len(errs) == 0 { t.Fatalf("expected validation error when both dul_base_ids and dul_base are provided") } }) } func TestContactHandlerList(t *testing.T) { t.Run("success with normalized pagination", func(t *testing.T) { userID := uuidv7.MustBytes() svc := &stubContactService{ listItems: []contact.ContactListItem{{ UserID: userID, RoleCode: "staff", RoleName: "Staff", Email: "staff@example.com", FirstName: "Jane", LastName: "Doe", IsActive: true, }}, listTotal: 1, } app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts?role=staff&pilot_category=regular&search=jane&sort=-sortkey&limit=500&offset=-4", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if svc.lastFilter.Limit != 0 || svc.lastFilter.Offset != 0 { t.Fatalf("expected normalized pagination, got %#v", svc.lastFilter) } if svc.lastFilter.Sort != "-sortkey" { t.Fatalf("expected sort forwarded, got %#v", svc.lastFilter) } }) t.Run("supports role alias and special flag filters", func(t *testing.T) { svc := &stubContactService{listTotal: 0} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact( t, app, http.MethodGet, "/contacts?role=technicians&chief_doctor=true&chief_technician=1&responsible_complaint=yes&chief_pilot=true&ifr_qualified=true", nil, ) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if svc.lastFilter.Role != "technician" { t.Fatalf("expected role alias normalized, got %q", svc.lastFilter.Role) } if svc.lastFilter.ChiefDoctor == nil || !*svc.lastFilter.ChiefDoctor { t.Fatalf("expected chief_doctor=true") } if svc.lastFilter.ChiefTechnician == nil || !*svc.lastFilter.ChiefTechnician { t.Fatalf("expected chief_technician=true") } if svc.lastFilter.ResponsibleComplaint == nil || !*svc.lastFilter.ResponsibleComplaint { t.Fatalf("expected responsible_complaint=true") } if svc.lastFilter.ChiefPilot == nil || !*svc.lastFilter.ChiefPilot { t.Fatalf("expected chief_pilot=true") } if svc.lastFilter.IFRQualified == nil || !*svc.lastFilter.IFRQualified { t.Fatalf("expected ifr_qualified=true") } }) t.Run("list error", func(t *testing.T) { svc := &stubContactService{listErr: errors.New("list failed")} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts", nil) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestContactHandlerListDatatable(t *testing.T) { t.Run("success with page size fallback", func(t *testing.T) { svc := &stubContactService{listTotal: 2} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts/dt?page=0&size=0&draw=9&role=pilot&sort=note", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if svc.lastFilter.Limit != 20 || svc.lastFilter.Offset != 0 || svc.lastFilter.Role != "pilot" { t.Fatalf("unexpected filter %#v", svc.lastFilter) } if svc.lastFilter.Sort != "note" { t.Fatalf("expected sort forwarded, got %#v", svc.lastFilter) } }) t.Run("supports special flag filters", func(t *testing.T) { svc := &stubContactService{listTotal: 0} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact( t, app, http.MethodGet, "/contacts/dt?chief_doctor=false&chief_technician=0&responsible_complaint=no&chief_pilot=false&ifr_qualified=false", nil, ) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if svc.lastFilter.ChiefDoctor == nil || *svc.lastFilter.ChiefDoctor { t.Fatalf("expected chief_doctor=false") } if svc.lastFilter.ChiefTechnician == nil || *svc.lastFilter.ChiefTechnician { t.Fatalf("expected chief_technician=false") } if svc.lastFilter.ResponsibleComplaint == nil || *svc.lastFilter.ResponsibleComplaint { t.Fatalf("expected responsible_complaint=false") } if svc.lastFilter.ChiefPilot == nil || *svc.lastFilter.ChiefPilot { t.Fatalf("expected chief_pilot=false") } if svc.lastFilter.IFRQualified == nil || *svc.lastFilter.IFRQualified { t.Fatalf("expected ifr_qualified=false") } }) t.Run("list error", func(t *testing.T) { svc := &stubContactService{listErr: errors.New("datatable failed")} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts/dt", nil) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestContactHandlerGetAndWriteByID(t *testing.T) { t.Run("invalid uuid", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts/not-uuid", nil) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{getErr: errors.New("get failed")} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts/"+id, nil) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("not found", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts/"+id, nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{getItem: &contact.ContactListItem{ RoleCode: "doctor", RoleName: "Doctor", Email: "doctor@example.com", FirstName: "Doc", LastName: "Tor", IsActive: true, }} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodGet, "/contacts/"+id, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) } func TestContactHandlerUpdate(t *testing.T) { userID := uuidv7.MustBytes() id := mustUUIDStringContact(t, userID) t.Run("invalid uuid", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/not-uuid", []byte(`{}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, []byte(`{}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("not found", func(t *testing.T) { svc := &stubContactService{} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"first_name":"Jane"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) t.Run("type must be general contact_update", func(t *testing.T) { svc := &stubContactService{getItem: &contact.ContactListItem{RoleCode: "staff", RoleName: "Staff"}} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_staff_update","attributes":{"first_name":"Jane"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("mismatched profile payload is rejected", func(t *testing.T) { svc := &stubContactService{getItem: &contact.ContactListItem{RoleCode: "staff", RoleName: "Staff"}} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"pilot":{"short_name":"PJ"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("success with multiple role specific payloads for assigned roles", func(t *testing.T) { rolePilotID := uuidv7.MustBytes() roleAirRescuerID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, rolePilotID, "Pilot", "pilot") + "," + mustRoleRawContact(t, roleAirRescuerID, "Air Rescuer", "air_rescuer") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "pilot", RoleName: "Pilot", RolesRaw: &rolesRaw, }, resolveRoleCodeByID: map[string]string{ string(rolePilotID): "pilot", string(roleAirRescuerID): "air_rescuer", }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_id":"` + mustUUIDStringContact(t, rolePilotID) + `","role_ids":["` + mustUUIDStringContact(t, rolePilotID) + `","` + mustUUIDStringContact(t, roleAirRescuerID) + `"],"pilot":{"short_name":"P"},"air_rescuer":{"short_name":"AR"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(svc.lastUpdate.Profiles) != 2 { t.Fatalf("expected 2 profile patches, got %d", len(svc.lastUpdate.Profiles)) } }) t.Run("success", func(t *testing.T) { svc := &stubContactService{getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", Email: "updated@example.com", FirstName: "Jane", LastName: "Doe", }} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"first_name":"Jane","staff":{"short_name":"PJ"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if svc.lastUpdate.Profile.ShortName == nil || *svc.lastUpdate.Profile.ShortName != "PJ" { t.Fatalf("expected profile patch on update") } }) t.Run("empty strings in update payload clear role profile fields", func(t *testing.T) { svc := &stubContactService{getItem: &contact.ContactListItem{ RoleCode: "air_rescuer", RoleName: "Air Rescuer", }} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"air_rescuer":{"short_name":"","location":" ","note":"","postcode":"","street_line":""}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if svc.lastUpdate.Profile.ShortName == nil || *svc.lastUpdate.Profile.ShortName != "" { t.Fatalf("expected short_name to be explicitly cleared, got %#v", svc.lastUpdate.Profile.ShortName) } if svc.lastUpdate.Profile.Location == nil || *svc.lastUpdate.Profile.Location != "" { t.Fatalf("expected location to be explicitly cleared, got %#v", svc.lastUpdate.Profile.Location) } if svc.lastUpdate.Profile.Note == nil || *svc.lastUpdate.Profile.Note != "" { t.Fatalf("expected note to be explicitly cleared, got %#v", svc.lastUpdate.Profile.Note) } if svc.lastUpdate.Profile.Postcode == nil || *svc.lastUpdate.Profile.Postcode != "" { t.Fatalf("expected postcode to be explicitly cleared, got %#v", svc.lastUpdate.Profile.Postcode) } if svc.lastUpdate.Profile.StreetLine == nil || *svc.lastUpdate.Profile.StreetLine != "" { t.Fatalf("expected street_line to be explicitly cleared, got %#v", svc.lastUpdate.Profile.StreetLine) } }) t.Run("success update role_ids and profile for assigned role", func(t *testing.T) { roleStaffID := uuidv7.MustBytes() roleAirRescuerID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, roleStaffID, "Staff", "staff") + "," + mustRoleRawContact(t, roleAirRescuerID, "Air Rescuer", "air_rescuer") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", Email: "updated@example.com", FirstName: "Jane", LastName: "Doe", RolesRaw: &rolesRaw, }, resolveRoleCodeByID: map[string]string{ string(roleStaffID): "staff", string(roleAirRescuerID): "air_rescuer", }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_id":"` + mustUUIDStringContact(t, roleAirRescuerID) + `","role_ids":["` + mustUUIDStringContact(t, roleAirRescuerID) + `","` + mustUUIDStringContact(t, roleStaffID) + `"],"air_rescuer":{"short_name":"AR"},"staff":{"short_name":"S"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !svc.lastUpdate.UpdateRoles { t.Fatalf("expected role updates enabled") } if len(svc.lastUpdate.RoleID) != 16 || string(svc.lastUpdate.RoleID) != string(roleAirRescuerID) { t.Fatalf("expected primary role updated to air_rescuer") } if len(svc.lastUpdate.RoleIDs) != 2 { t.Fatalf("expected 2 role ids, got %d", len(svc.lastUpdate.RoleIDs)) } if len(svc.lastUpdate.Profiles) != 2 { t.Fatalf("expected two role profile patches") } foundAirRescuer := false foundStaff := false for i := range svc.lastUpdate.Profiles { switch svc.lastUpdate.Profiles[i].RoleCode { case "air_rescuer": foundAirRescuer = svc.lastUpdate.Profiles[i].Patch.ShortName != nil && *svc.lastUpdate.Profiles[i].Patch.ShortName == "AR" case "staff": foundStaff = svc.lastUpdate.Profiles[i].Patch.ShortName != nil && *svc.lastUpdate.Profiles[i].Patch.ShortName == "S" } } if !foundAirRescuer || !foundStaff { t.Fatalf("expected air_rescuer and staff patches") } }) t.Run("success update role_ids only promotes first role_id as primary", func(t *testing.T) { rolePilotID := uuidv7.MustBytes() roleAirRescuerID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, rolePilotID, "Pilot", "pilot") + "," + mustRoleRawContact(t, roleAirRescuerID, "Air Rescuer", "air_rescuer") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "pilot", RoleName: "Pilot", Email: "updated@example.com", FirstName: "Jane", LastName: "Doe", RolesRaw: &rolesRaw, }, resolveRoleCodeByID: map[string]string{ string(rolePilotID): "pilot", string(roleAirRescuerID): "air_rescuer", }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_ids":["` + mustUUIDStringContact(t, roleAirRescuerID) + `","` + mustUUIDStringContact(t, rolePilotID) + `"],"air_rescuer":{"short_name":"AR"},"pilot":{"short_name":"P"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !svc.lastUpdate.UpdateRoles { t.Fatalf("expected role updates enabled") } if len(svc.lastUpdate.RoleID) != 16 || string(svc.lastUpdate.RoleID) != string(roleAirRescuerID) { t.Fatalf("expected primary role updated to first role_ids item (air_rescuer)") } if len(svc.lastUpdate.RoleIDs) != 2 { t.Fatalf("expected 2 role ids, got %d", len(svc.lastUpdate.RoleIDs)) } }) t.Run("role_ids update requires matching profile payloads", func(t *testing.T) { roleStaffID := uuidv7.MustBytes() roleAirRescuerID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, roleStaffID, "Staff", "staff") + "," + mustRoleRawContact(t, roleAirRescuerID, "Air Rescuer", "air_rescuer") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", RolesRaw: &rolesRaw, }, resolveRoleCodeByID: map[string]string{ string(roleStaffID): "staff", string(roleAirRescuerID): "air_rescuer", }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_ids":["` + mustUUIDStringContact(t, roleAirRescuerID) + `","` + mustUUIDStringContact(t, roleStaffID) + `"],"air_rescuer":{"short_name":"AR"}}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid role_id format", func(t *testing.T) { svc := &stubContactService{ getItem: &contact.ContactListItem{RoleCode: "staff", RoleName: "Staff"}, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_id":"bad"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid role_ids format", func(t *testing.T) { svc := &stubContactService{ getItem: &contact.ContactListItem{RoleCode: "staff", RoleName: "Staff"}, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_ids":["bad"]}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("success clear all roles with empty role_ids", func(t *testing.T) { roleStaffID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, roleStaffID, "Staff", "staff") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", RolesRaw: &rolesRaw, }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_ids":[]}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !svc.lastUpdate.UpdateRoles { t.Fatalf("expected role updates enabled") } if len(svc.lastUpdate.RoleID) != 0 { t.Fatalf("expected empty primary role id") } if len(svc.lastUpdate.RoleIDs) != 0 { t.Fatalf("expected empty role ids") } }) t.Run("success clear all roles with empty role_id string", func(t *testing.T) { roleStaffID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, roleStaffID, "Staff", "staff") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", RolesRaw: &rolesRaw, }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_id":"","role_ids":[]}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !svc.lastUpdate.UpdateRoles { t.Fatalf("expected role updates enabled") } if len(svc.lastUpdate.RoleID) != 0 { t.Fatalf("expected empty primary role id") } if len(svc.lastUpdate.RoleIDs) != 0 { t.Fatalf("expected empty role ids") } }) t.Run("success clear all roles with only empty role_id string", func(t *testing.T) { roleStaffID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, roleStaffID, "Staff", "staff") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", RolesRaw: &rolesRaw, }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_id":""}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !svc.lastUpdate.UpdateRoles || len(svc.lastUpdate.RoleID) != 0 || len(svc.lastUpdate.RoleIDs) != 0 { t.Fatalf("expected clear roles request") } }) t.Run("success clear all roles with role_id null string", func(t *testing.T) { roleStaffID := uuidv7.MustBytes() rolesRaw := mustRoleRawContact(t, roleStaffID, "Staff", "staff") svc := &stubContactService{ getItem: &contact.ContactListItem{ RoleCode: "staff", RoleName: "Staff", RolesRaw: &rolesRaw, }, } app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_update","attributes":{"role_id":"null"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !svc.lastUpdate.UpdateRoles || len(svc.lastUpdate.RoleID) != 0 || len(svc.lastUpdate.RoleIDs) != 0 { t.Fatalf("expected clear roles request") } }) } func TestContactHandlerDelete(t *testing.T) { t.Run("invalid uuid", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodDelete, "/contacts/not-uuid", nil) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("not found", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{deleteErr: gorm.ErrRecordNotFound} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodDelete, "/contacts/"+id, nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodDelete, "/contacts/"+id, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) } func TestContactHandlerSimpleMutations(t *testing.T) { userID := mustUUIDStringContact(t, uuidv7.MustBytes()) getItem := &contact.ContactListItem{ RoleCode: "technician", RoleName: "Technician", Email: "tech@example.com", FirstName: "Tech", LastName: "Nician", IsActive: true, } tests := []struct { name string method string path string body []byte assert func(t *testing.T, svc *stubContactService) }{ { name: "status success", method: http.MethodPatch, path: "/contacts/" + userID + "/status", body: []byte(`{"data":{"type":"contact_status_update","attributes":{"is_active":true}}}`), assert: func(t *testing.T, svc *stubContactService) { if !svc.lastIsActive { t.Fatalf("expected active flag true") } }, }, { name: "role success", method: http.MethodPatch, path: "/contacts/" + userID + "/role", body: []byte(`{"data":{"type":"contact_role_update","attributes":{"role_code":"pilot"}}}`), assert: func(t *testing.T, svc *stubContactService) { if svc.lastRoleCode != "pilot" { t.Fatalf("expected role code recorded") } }, }, { name: "pilot category success", method: http.MethodPatch, path: "/contacts/" + userID + "/pilot-category", body: []byte(`{"data":{"type":"contact_pilot_category_update","attributes":{"pilot_category":"freelance"}}}`), assert: func(t *testing.T, svc *stubContactService) { if svc.lastPilotCategory != "freelance" { t.Fatalf("expected pilot category recorded") } }, }, { name: "license success", method: http.MethodPatch, path: "/contacts/" + userID + "/license", body: []byte(`{"data":{"type":"contact_license_update","attributes":{"license_no":"LIC"}}}`), assert: func(t *testing.T, svc *stubContactService) { if svc.lastLicenseNo == nil || *svc.lastLicenseNo != "LIC" { t.Fatalf("expected license fields recorded") } }, }, { name: "chief flags success", method: http.MethodPatch, path: "/contacts/" + userID + "/chief-flags", body: []byte(`{"data":{"type":"contact_chief_flags_update","attributes":{"is_chief_pilot":true,"is_chief_technician":false}}}`), assert: func(t *testing.T, svc *stubContactService) { if svc.lastChiefFlags.IsChiefPilot == nil || !*svc.lastChiefFlags.IsChiefPilot { t.Fatalf("expected chief pilot flag recorded") } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &stubContactService{getItem: getItem} app := newContactTestApp(NewContactHandler(svc, nil)) resp, _ := doJSONRequestContact(t, app, tc.method, tc.path, tc.body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } tc.assert(t, svc) }) } } func TestContactHandlerSimpleMutationsErrors(t *testing.T) { t.Run("status invalid uuid", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/not-uuid/status", []byte(`{}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("status service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{updateStatusErr: errors.New("status failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_status_update","attributes":{"is_active":false}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/status", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("role invalid json", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/role", []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("role validation error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) body := []byte(`{"data":{"type":"contact_role_update","attributes":{"role_code":"wrong"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/role", body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("role service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{updateRoleErr: errors.New("role failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_role_update","attributes":{"role_code":"pilot"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/role", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("pilot category invalid json", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/pilot-category", []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("pilot category validation error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) body := []byte(`{"data":{"type":"contact_pilot_category_update","attributes":{"pilot_category":"wrong"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/pilot-category", body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("pilot category service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{updatePilotCategoryErr: errors.New("pilot category failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_pilot_category_update","attributes":{"pilot_category":"regular"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/pilot-category", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("license invalid json", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/license", []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("license service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{updateLicenseErr: errors.New("license failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_license_update","attributes":{"license_no":"LIC"}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/license", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("chief flags invalid json", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/chief-flags", []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("chief flags service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{updateChiefFlagsErr: errors.New("chief failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_chief_flags_update","attributes":{"is_chief_physician":true}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPatch, "/contacts/"+id+"/chief-flags", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestContactHandlerBulkUpdate(t *testing.T) { t.Run("invalid json", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/bulk-update", []byte(`{"data":`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("validation error", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/bulk-update", []byte(`{}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid uuid", func(t *testing.T) { app := newContactTestApp(NewContactHandler(&stubContactService{}, nil)) body := []byte(`{"data":{"type":"contact_bulk_update","attributes":{"user_ids":["bad"],"is_active":true}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/bulk-update", body) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("service error", func(t *testing.T) { id := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{bulkUpdateErr: errors.New("bulk failed")} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_bulk_update","attributes":{"user_ids":["` + id + `"],"is_active":false}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/bulk-update", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("success", func(t *testing.T) { id1 := mustUUIDStringContact(t, uuidv7.MustBytes()) id2 := mustUUIDStringContact(t, uuidv7.MustBytes()) svc := &stubContactService{bulkUpdateCount: 2} app := newContactTestApp(NewContactHandler(svc, nil)) body := []byte(`{"data":{"type":"contact_bulk_update","attributes":{"user_ids":["` + id1 + `","` + id2 + `"],"is_active":true}}}`) resp, _ := doJSONRequestContact(t, app, http.MethodPost, "/contacts/bulk-update", body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(svc.lastBulkIDs) != 2 || !svc.lastBulkIsActive { t.Fatalf("expected bulk update recorded") } }) } func TestContactHandlerProfilePatchHelpers(t *testing.T) { now := time.Now().UTC() later := now.Add(24 * time.Hour) createPatch := profilePatchFromCreate("pilot", dto.ContactCreateAttributes{ Pilot: &dto.PilotProfileAttributes{ PilotCategory: "regular", ShortName: "P", LicenseNo: "L", LicenseIssuedAt: &now, LicenseExpiredAt: &later, TechnicianLicenseNo: "T", HasIFRQualification: boolPtrContact(true), IsChiefPilot: boolPtrContact(true), DULBaseIDs: []string{}, TotalFlightMinutes: intPtrContact(1), ResponsiblePilotMinutes: intPtrContact(2), SecondPilotMinutes: intPtrContact(3), DoubleCommandMinutes: intPtrContact(4), FlightInstructorMinutes: intPtrContact(5), NightFlightMinutes: intPtrContact(6), IFRFlightMinutes: intPtrContact(7), Location: "Loc", Postcode: "123", StreetLine: "Street", WeightKG: floatPtrContact(70.5), }, Doctor: &dto.DoctorProfileAttributes{ ShortName: "D", Specialization: "Spec", SubSpecialization: "Sub", IsChiefPhysician: boolPtrContact(true), Location: "DocLoc", Postcode: "456", StreetLine: "DocStreet", }, AirRescuer: &dto.AirRescuerProfileAttributes{ ShortName: "A", ResponsibleFlightRescuer: boolPtrContact(true), Location: "AirLoc", Postcode: "789", StreetLine: "AirStreet", }, Technician: &dto.TechnicianProfileAttributes{ ShortName: "T", LicenseNo: "TEC", IsChiefTechnician: boolPtrContact(true), IsResponsibleComplaints: boolPtrContact(true), Location: "TechLoc", Postcode: "101", StreetLine: "TechStreet", }, FlightAssistant: &dto.FlightAssistantProfileAttributes{ ShortName: "F", Location: "FlightLoc", Postcode: "202", StreetLine: "FlightStreet", }, Staff: &dto.StaffProfileAttributes{ ShortName: "S", RoleLabel: "Admin", Location: "StaffLoc", Postcode: "303", StreetLine: "StaffStreet", }, }) if createPatch.WeightKG == nil || *createPatch.WeightKG != 70.5 { t.Fatalf("expected pilot weight copied") } if createPatch.ResponsibleFlightRescuer != nil { t.Fatalf("expected pilot responsible flight rescuer ignored on create") } if createPatch.RoleLabel != nil { t.Fatalf("expected non-pilot fields to be ignored on create") } airRescuerPatch := profilePatchFromCreate("air_rescuer", dto.ContactCreateAttributes{ AirRescuer: &dto.AirRescuerProfileAttributes{ ShortName: "Air", ResponsibleFlightRescuer: boolPtrContact(true), }, }) if airRescuerPatch.ResponsibleFlightRescuer == nil || !*airRescuerPatch.ResponsibleFlightRescuer { t.Fatalf("expected air rescuer responsible flight rescuer copied on create") } updatePatch := profilePatchFromUpdate("staff", dto.ContactUpdateAttributes{ Pilot: &dto.PilotProfileUpdateAttributes{ PilotCategory: strPtrContact("dry_lease"), ShortName: strPtrContact("Pilot"), }, Doctor: &dto.DoctorProfileUpdateAttributes{}, AirRescuer: &dto.AirRescuerProfileUpdateAttributes{ ResponsibleFlightRescuer: boolPtrContact(true), }, Technician: &dto.TechnicianProfileUpdateAttributes{ IsResponsibleComplaints: boolPtrContact(true), }, FlightAssistant: &dto.FlightAssistantProfileUpdateAttributes{ShortName: strPtrContact("FA")}, Staff: &dto.StaffProfileUpdateAttributes{ ShortName: strPtrContact("Staff"), RoleLabel: strPtrContact("Coordinator"), }, }) if updatePatch.ShortName == nil || *updatePatch.ShortName != "Staff" { t.Fatalf("expected update profile patch") } if updatePatch.RoleLabel == nil || *updatePatch.RoleLabel != "Coordinator" { t.Fatalf("expected staff fields copied on update") } if updatePatch.PilotCategory != nil || updatePatch.ResponsibleFlightRescuer != nil || updatePatch.IsResponsibleComplaints != nil { t.Fatalf("expected non-staff fields to be ignored on update") } pilotPatch := contact.ProfilePatch{} dulBaseID := mustUUIDStringContact(t, uuidv7.MustBytes()) fillPatchFromPilot(&pilotPatch, &dto.PilotProfileAttributes{ DULBase: []dto.DULBase{{ID: dulBaseID, Name: "Base A", Category: "hems"}}, }) if !pilotPatch.DULBaseIDsSet || len(pilotPatch.DULBaseIDs) != 1 { t.Fatalf("expected dul_base object payload mapped to DUL base ids") } fillPatchFromPilot(nil, nil) fillPatchFromDoctor(nil, nil) fillPatchFromAirRescuer(nil, nil) fillPatchFromTechnician(nil, nil) fillPatchFromFlightAssistant(nil, nil) fillPatchFromStaff(nil, nil) } func TestContactHandlerHelpers(t *testing.T) { if optionalStr(" ") != nil { t.Fatalf("expected nil optional string") } if got := optionalStr(" abc "); got == nil || *got != "abc" { t.Fatalf("expected trimmed optional string") } if got := strPtr("x"); got == nil || *got != "x" { t.Fatalf("expected string pointer") } if got := contactUUIDString([]byte("bad")); got != "" { t.Fatalf("expected empty uuid string on invalid bytes") } if attrs := contactAttributes(nil, false); len(attrs) != 0 { t.Fatalf("expected empty attrs for nil item") } if roles := contactRoles(nil); roles == nil || len(roles) != 0 { t.Fatalf("expected empty roles for nil raw") } roleID := uuidv7.MustBytes() roleIDHex := strings.ToLower(hex.EncodeToString(roleID)) roleNameHex := strings.ToLower(hex.EncodeToString([]byte("Pilot"))) raw := roleIDHex + "|" + roleNameHex + "|pilot" roles := contactRoles(&raw) if len(roles) != 1 { t.Fatalf("expected one role, got %d", len(roles)) } if roles[0]["role_code"] != "pilot" || roles[0]["role_name"] != "Pilot" { t.Fatalf("unexpected role payload: %#v", roles[0]) } if intStatus(http.StatusBadRequest) != "400" || intStatus(http.StatusNotFound) != "404" { t.Fatalf("unexpected int status mapping") } } func TestContactAttributesByRole(t *testing.T) { now := time.Now().UTC() creator := uuidv7.MustBytes() str := "value" yes := true n := 10 kg := 80.5 cases := []contact.ContactListItem{ { CreatedBy: creator, RoleCode: "pilot", RoleName: "Pilot", Email: "p@example.com", FirstName: "P", LastName: "One", PilotCategory: "regular", PilotShortName: &str, PilotLicenseNo: &str, PilotLicenseIssuedAt: &now, PilotLicenseExpiredAt: &now, PilotTechnicianLicenseNo: &str, PilotHasIFRQualification: &yes, PilotIsChiefPilot: &yes, PilotIsDUL: &yes, PilotTotalFlightMinutes: &n, PilotResponsiblePilotMinutes: &n, PilotSecondPilotMinutes: &n, PilotDoubleCommandMinutes: &n, PilotFlightInstructorMinutes: &n, PilotNightFlightMinutes: &n, PilotIFRFlightMinutes: &n, PilotLocation: &str, PilotPostcode: &str, PilotStreetLine: &str, PilotWeightKG: &kg, PilotPhotoFileID: &str, }, { CreatedBy: creator, RoleCode: "doctor", RoleName: "Doctor", Email: "d@example.com", FirstName: "D", LastName: "One", DoctorShortName: &str, DoctorSpecialization: &str, DoctorSubSpecialization: &str, DoctorIsChiefPhysician: &yes, DoctorLocation: &str, DoctorPostcode: &str, DoctorStreetLine: &str, DoctorPhotoFileID: &str, }, { CreatedBy: creator, RoleCode: "air_rescuer", RoleName: "Air Rescuer", Email: "a@example.com", FirstName: "A", LastName: "One", AirRescuerShortName: &str, AirRescuerResponsibleFlightRescuer: &yes, AirRescuerLocation: &str, AirRescuerPostcode: &str, AirRescuerStreetLine: &str, AirRescuerPhotoFileID: &str, }, { CreatedBy: creator, RoleCode: "technician", RoleName: "Technician", Email: "t@example.com", FirstName: "T", LastName: "One", TechnicianShortName: &str, TechnicianLicenseNo: &str, TechnicianIsChiefTechnician: &yes, TechnicianIsResponsibleComplaints: &yes, TechnicianLocation: &str, TechnicianPostcode: &str, TechnicianStreetLine: &str, TechnicianPhotoFileID: &str, }, { CreatedBy: creator, RoleCode: "flight_assistant", RoleName: "Flight Assistant", Email: "f@example.com", FirstName: "F", LastName: "One", FlightAssistantShortName: &str, FlightAssistantLocation: &str, FlightAssistantPostcode: &str, FlightAssistantStreetLine: &str, FlightAssistantPhotoFileID: &str, }, { CreatedBy: creator, RoleCode: "staff", RoleName: "Staff", Email: "s@example.com", FirstName: "S", LastName: "One", StaffShortName: &str, StaffRoleLabel: &str, StaffLocation: &str, StaffPostcode: &str, StaffStreetLine: &str, StaffPhotoFileID: &str, }, } for _, item := range cases { attrs := contactAttributes(&item, false) if len(attrs) == 0 { t.Fatalf("expected attrs for role %s", item.RoleCode) } if attrs["email"] == nil { t.Fatalf("expected base attrs for role %s", item.RoleCode) } if _, ok := attrs["set_password"].(bool); !ok { t.Fatalf("expected set_password bool for role %s", item.RoleCode) } if attrs["created_by"] == "" { t.Fatalf("expected created_by for role %s", item.RoleCode) } if _, ok := attrs["updated_by"].(string); !ok { t.Fatalf("expected updated_by string for role %s", item.RoleCode) } if _, ok := attrs["created_at"].(string); !ok { t.Fatalf("expected created_at string for role %s", item.RoleCode) } if _, ok := attrs["updated_at"].(string); !ok { t.Fatalf("expected updated_at string for role %s", item.RoleCode) } if item.RoleCode == "pilot" { if profile, ok := attrs["pilot"].(map[string]any); ok && profile["responsible_flight_rescuer"] != nil { t.Fatalf("expected pilot responsible_flight_rescuer removed from response") } if _, ok := attrs["license_no"]; ok { t.Fatalf("expected top-level license_no removed for pilot") } if _, ok := attrs["pilot_category"]; ok { t.Fatalf("expected top-level pilot_category removed for pilot") } } if item.RoleCode == "doctor" { if _, ok := attrs["medical_license"]; ok { t.Fatalf("expected top-level medical_license removed for doctor") } if _, ok := attrs["license_no"]; ok { t.Fatalf("expected top-level license_no removed for doctor") } if _, ok := attrs["pilot_category"]; ok { t.Fatalf("expected top-level pilot_category removed for doctor") } } if item.RoleCode == "air_rescuer" { profile, ok := attrs["air_rescuer"].(map[string]any) if !ok || profile["responsible_flight_rescuer"] == nil { t.Fatalf("expected air rescuer responsible_flight_rescuer in response") } if _, ok := attrs["license_no"]; ok { t.Fatalf("expected top-level license_no removed for air_rescuer") } if _, ok := attrs["medical_license"]; ok { t.Fatalf("expected top-level medical_license removed for air_rescuer") } if _, ok := attrs["pilot_category"]; ok { t.Fatalf("expected top-level pilot_category removed for air_rescuer") } } if item.RoleCode == "flight_assistant" { if _, ok := attrs["license_no"]; ok { t.Fatalf("expected top-level license_no removed for flight_assistant") } if _, ok := attrs["medical_license"]; ok { t.Fatalf("expected top-level medical_license removed for flight_assistant") } if _, ok := attrs["pilot_category"]; ok { t.Fatalf("expected top-level pilot_category removed for flight_assistant") } } if item.RoleCode == "technician" { if _, ok := attrs["medical_license"]; ok { t.Fatalf("expected top-level medical_license removed for technician") } if _, ok := attrs["pilot_category"]; ok { t.Fatalf("expected top-level pilot_category removed for technician") } } if item.RoleCode == "staff" { if _, ok := attrs["license_no"]; ok { t.Fatalf("expected top-level license_no removed for staff") } if _, ok := attrs["medical_license"]; ok { t.Fatalf("expected top-level medical_license removed for staff") } if _, ok := attrs["pilot_category"]; ok { t.Fatalf("expected top-level pilot_category removed for staff") } } } baseOnly := contactAttributes(&contact.ContactListItem{ RoleCode: "pilot", RoleName: "Pilot", Email: "base@example.com", FirstName: "Base", LastName: "Only", SortKey: intPtrContactHandler(2), Note: "base-note", }, false) if baseOnly["sortkey"] == nil || baseOnly["note"] != "base-note" { t.Fatalf("expected sortkey and note in base attrs") } if baseOnly["set_password"] != true { t.Fatalf("expected set_password=true when password is empty") } if _, ok := baseOnly["pilot"]; ok { t.Fatalf("expected pilot branch skipped when profile fields nil") } categoryOnly := contactAttributes(&contact.ContactListItem{ RoleCode: "pilot", RoleName: "Pilot", Email: "cat@example.com", FirstName: "Cat", LastName: "Only", PilotCategory: "freelance", }, false) pilotProfile, ok := categoryOnly["pilot"].(map[string]any) if !ok { t.Fatalf("expected pilot branch included when pilot_category is present") } if pilotProfile["pilot_category"] != "freelance" { t.Fatalf("expected pilot_category propagated, got %#v", pilotProfile["pilot_category"]) } pilotRoleID := uuidv7.MustBytes() staffRoleID := uuidv7.MustBytes() rolesRaw := strings.ToLower(hex.EncodeToString(pilotRoleID)) + "|" + strings.ToLower(hex.EncodeToString([]byte("Pilot"))) + "|pilot," + strings.ToLower(hex.EncodeToString(staffRoleID)) + "|" + strings.ToLower(hex.EncodeToString([]byte("Staff"))) + "|staff" multiRole := contactAttributes(&contact.ContactListItem{ RoleCode: "staff", RoleName: "staff", RolesRaw: &rolesRaw, Email: "multi@example.com", FirstName: "Multi", LastName: "Role", PilotCategory: "regular", PilotShortName: strPtrContact("Pilot"), StaffShortName: strPtrContact("Staff"), StaffRoleLabel: strPtrContact("Ops"), StaffLocation: strPtrContact("Jakarta"), StaffPostcode: strPtrContact("12950"), StaffStreetLine: strPtrContact("Jl"), }, false) if _, ok := multiRole["pilot"]; !ok { t.Fatalf("expected pilot profile in multi-role payload") } if _, ok := multiRole["staff"]; !ok { t.Fatalf("expected staff profile in multi-role payload") } pilotTechRolesRaw := strings.ToLower(hex.EncodeToString(pilotRoleID)) + "|" + strings.ToLower(hex.EncodeToString([]byte("Pilot"))) + "|pilot," + strings.ToLower(hex.EncodeToString(staffRoleID)) + "|" + strings.ToLower(hex.EncodeToString([]byte("Technician"))) + "|technician" pilotTechnician := contactAttributes(&contact.ContactListItem{ RoleCode: "pilot", RoleName: "pilot", RolesRaw: &pilotTechRolesRaw, Email: "pilot-tech@example.com", FirstName: "Pilot", LastName: "Tech", PilotCategory: "regular", PilotShortName: strPtrContact("Pilot"), TechnicianShortName: strPtrContact("Tech"), TechnicianLicenseNo: strPtrContact("TECH-1"), TechnicianIsChiefTechnician: boolPtrContact(true), TechnicianIsResponsibleComplaints: boolPtrContact(true), }, false) if _, ok := pilotTechnician["pilot"]; !ok { t.Fatalf("expected pilot profile in pilot+technician payload") } if _, ok := pilotTechnician["technician"]; !ok { t.Fatalf("expected technician profile in pilot+technician payload") } } func intPtrContactHandler(v int) *int { return &v }