package service import ( "context" "errors" "strings" "testing" "time" dutyroster "wucher/internal/domain/duty_roster" "wucher/internal/shared/pkg/uuidv7" requestdto "wucher/internal/transport/http/dto/request" responsedto "wucher/internal/transport/http/dto/response" ) type hemsDutyRosterRepoMock struct { createCalled bool updateCalled bool deleteCalled bool getCalled bool crewsCalled bool listCalled bool basesCalled bool shiftCalled bool lastCreateRow *dutyroster.DutyRoster lastCreateCrews []dutyroster.DutyRosterCrew lastUpdateRow *dutyroster.DutyRoster lastUpdateCrews []dutyroster.DutyRosterCrew lastDeleteID []byte lastDeleteBy []byte lastGetID []byte lastRosterIDs [][]byte lastListBaseID []byte lastListFrom time.Time lastListTo time.Time lastBasesID []byte lastShiftBaseID []byte lastShiftType string lastHeaderType string lastListType string lastBasesType string insertedCrews []dutyroster.DutyRosterCrew headerResult *dutyroster.HeaderView crewsResult []dutyroster.CrewView headersResult []dutyroster.HeaderView basesResult []dutyroster.BaseView shiftResult string createErr error updateErr error deleteErr error getErr error crewsErr error listErr error basesErr error shiftErr error } func (m *hemsDutyRosterRepoMock) InTx(_ context.Context, fn func(txRepo dutyroster.TxRepository) error) error { return fn(m) } func (m *hemsDutyRosterRepoMock) CreateHeader(_ context.Context, row *dutyroster.DutyRoster) error { return m.Create(context.Background(), row, nil) } func (m *hemsDutyRosterRepoMock) UpdateHeader(_ context.Context, row *dutyroster.DutyRoster) error { return m.Update(context.Background(), row, nil) } func (m *hemsDutyRosterRepoMock) ListActiveCrewsByRosterID(_ context.Context, _ []byte) ([]dutyroster.DutyRosterCrew, error) { return []dutyroster.DutyRosterCrew{}, nil } func (m *hemsDutyRosterRepoMock) InsertCrew(_ context.Context, row *dutyroster.DutyRosterCrew) error { if row != nil { m.insertedCrews = append(m.insertedCrews, *row) } return nil } func (m *hemsDutyRosterRepoMock) CrewExists(_ context.Context, _ dutyroster.DutyRosterCrew) (bool, error) { return false, nil } func (m *hemsDutyRosterRepoMock) SoftDeleteCrewByUser(_ context.Context, _ []byte, _, _ string, _ []byte, _ []byte) error { return nil } func (m *hemsDutyRosterRepoMock) FindRosterIDByBaseDate(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return nil, nil } func (m *hemsDutyRosterRepoMock) FindRosterIDsByBaseDate(_ context.Context, _ []byte, _ time.Time) ([][]byte, error) { return [][]byte{}, nil } func (m *hemsDutyRosterRepoMock) MatchCrewByFilter(_ context.Context, _ [][]byte, _ dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) { return []dutyroster.DeletedCrewResult{}, nil } func (m *hemsDutyRosterRepoMock) SoftDeleteCrewRows(_ context.Context, _ []dutyroster.DeletedCrewResult, _ []byte) error { return nil } func (m *hemsDutyRosterRepoMock) SoftDeleteCrewForDate(_ context.Context, _ []byte, _ time.Time, _ dutyroster.DutyRosterCrew, _ []byte) error { return nil } func (m *hemsDutyRosterRepoMock) HardDeleteHeader(_ context.Context, id []byte) error { m.deleteCalled = true m.lastDeleteID = id return m.deleteErr } func (m *hemsDutyRosterRepoMock) HardDeleteCrewsByRosterID(_ context.Context, id []byte) error { m.deleteCalled = true m.lastDeleteID = id return m.deleteErr } func (m *hemsDutyRosterRepoMock) GetRosterByID(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) { m.getCalled = true m.lastGetID = id if m.getErr != nil { return nil, m.getErr } return &dutyroster.DutyRoster{ ID: id, BaseID: m.lastBasesID, DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), }, nil } func (m *hemsDutyRosterRepoMock) LockRosterByID(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) { return m.GetRosterByID(context.Background(), id) } func (m *hemsDutyRosterRepoMock) SoftDeleteHeader(_ context.Context, id []byte, deletedBy []byte) error { m.deleteCalled = true m.lastDeleteID = id m.lastDeleteBy = deletedBy return m.deleteErr } func (m *hemsDutyRosterRepoMock) SoftDeleteCrewsByRosterID(_ context.Context, id []byte, deletedBy []byte) error { m.deleteCalled = true m.lastDeleteID = id m.lastDeleteBy = deletedBy return m.deleteErr } func (m *hemsDutyRosterRepoMock) Create(_ context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error { m.createCalled = true m.lastCreateRow = row m.lastCreateCrews = crews return m.createErr } func (m *hemsDutyRosterRepoMock) Update(_ context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error { m.updateCalled = true m.lastUpdateRow = row m.lastUpdateCrews = crews return m.updateErr } func (m *hemsDutyRosterRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error { m.deleteCalled = true m.lastDeleteID = id m.lastDeleteBy = deletedBy return m.deleteErr } func (m *hemsDutyRosterRepoMock) DeleteCrews(_ context.Context, id []byte, _ []dutyroster.DutyRosterCrew, deletedBy []byte) ([]dutyroster.DeletedCrewResult, error) { m.deleteCalled = true m.lastDeleteID = id m.lastDeleteBy = deletedBy return []dutyroster.DeletedCrewResult{}, m.deleteErr } func (m *hemsDutyRosterRepoMock) GetBaseDefaultShift(_ context.Context, baseID []byte, baseType string) (string, error) { m.shiftCalled = true m.lastShiftBaseID = baseID m.lastShiftType = baseType return m.shiftResult, m.shiftErr } func (m *hemsDutyRosterRepoMock) GetHeaderByID(_ context.Context, id []byte) (*dutyroster.HeaderView, error) { m.getCalled = true m.lastGetID = id return m.headerResult, m.getErr } func (m *hemsDutyRosterRepoMock) GetHeaderByIDByBaseType(_ context.Context, id []byte, baseType string) (*dutyroster.HeaderView, error) { m.lastHeaderType = baseType return m.GetHeaderByID(context.Background(), id) } func (m *hemsDutyRosterRepoMock) GetCrewsByRosterIDs(_ context.Context, rosterIDs [][]byte) ([]dutyroster.CrewView, error) { m.crewsCalled = true m.lastRosterIDs = rosterIDs return m.crewsResult, m.crewsErr } func (m *hemsDutyRosterRepoMock) ListHeadersByRange(_ context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) { m.listCalled = true m.lastListBaseID = baseID m.lastListFrom = from m.lastListTo = to return m.headersResult, m.listErr } func (m *hemsDutyRosterRepoMock) ListHeadersByRangeByBaseType(_ context.Context, baseID []byte, from, to time.Time, baseType string) ([]dutyroster.HeaderView, error) { m.lastListType = baseType return m.ListHeadersByRange(context.Background(), baseID, from, to) } func (m *hemsDutyRosterRepoMock) ListHEMSBases(_ context.Context, baseID []byte) ([]dutyroster.BaseView, error) { m.basesCalled = true m.lastBasesID = baseID return m.basesResult, m.basesErr } func (m *hemsDutyRosterRepoMock) ListBasesByType(_ context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) { m.lastBasesType = baseType m.basesCalled = true m.lastBasesID = baseID return m.basesResult, m.basesErr } func TestHemsDutyRosterService_ForwardsAllMethods(t *testing.T) { repo := &hemsDutyRosterRepoMock{ headerResult: &dutyroster.HeaderView{BaseName: "Base A"}, crewsResult: []dutyroster.CrewView{{RoleCode: "pilot"}}, headersResult: []dutyroster.HeaderView{{ BaseName: "Base A", }}, basesResult: []dutyroster.BaseView{{BaseName: "Base A"}}, } svc := NewDutyRosterService(repo) row := &dutyroster.DutyRoster{} crews := []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}} id := []byte("roster-id-123456") deletedBy := []byte("actor-id-1234567") baseID := []byte("base-id-12345678") from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) if err := svc.Create(context.Background(), row, crews); err != nil { t.Fatalf("create: %v", err) } if !repo.createCalled || repo.lastCreateRow != row { t.Fatalf("expected create forwarded") } if err := svc.Update(context.Background(), row, crews); err != nil { t.Fatalf("update: %v", err) } if !repo.updateCalled || repo.lastUpdateRow != row { t.Fatalf("expected update forwarded") } if err := svc.Delete(context.Background(), id, deletedBy); err != nil { t.Fatalf("delete: %v", err) } if !repo.deleteCalled || string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) { t.Fatalf("expected delete forwarded") } if _, err := svc.GetBaseDefaultShift(context.Background(), baseID, "hems"); err != nil { t.Fatalf("get base default shift: %v", err) } if !repo.shiftCalled || string(repo.lastShiftBaseID) != string(baseID) || repo.lastShiftType != "hems" { t.Fatalf("expected get base default shift forwarded") } gotHeader, err := svc.GetHeaderByID(context.Background(), id) if err != nil || gotHeader == nil || gotHeader.BaseName != "Base A" { t.Fatalf("expected get header forwarded") } if !repo.getCalled || string(repo.lastGetID) != string(id) { t.Fatalf("expected get header args forwarded") } if _, err := svc.GetHeaderByIDByBaseType(context.Background(), id, "base"); err != nil { t.Fatalf("expected get header by base type forwarded") } if repo.lastHeaderType == "" { t.Fatalf("expected base type get header path called") } gotCrews, err := svc.GetCrewsByRosterIDs(context.Background(), [][]byte{id}) if err != nil || len(gotCrews) != 1 { t.Fatalf("expected get crews forwarded") } if !repo.crewsCalled || len(repo.lastRosterIDs) != 1 { t.Fatalf("expected get crews args forwarded") } gotHeaders, err := svc.ListHeadersByRange(context.Background(), baseID, from, to) if err != nil || len(gotHeaders) != 1 { t.Fatalf("expected list headers forwarded") } if !repo.listCalled || string(repo.lastListBaseID) != string(baseID) || !repo.lastListFrom.Equal(from) || !repo.lastListTo.Equal(to) { t.Fatalf("expected list headers args forwarded") } if _, err := svc.ListHeadersByRangeByBaseType(context.Background(), baseID, from, to, "base"); err != nil { t.Fatalf("expected list headers by base type forwarded") } if repo.lastListType != "base" { t.Fatalf("expected base type forwarded to list headers, got %q", repo.lastListType) } gotBases, err := svc.ListHEMSBases(context.Background(), baseID) if err != nil || len(gotBases) != 1 { t.Fatalf("expected list bases forwarded") } if !repo.basesCalled || string(repo.lastBasesID) != string(baseID) { t.Fatalf("expected list bases args forwarded") } if _, err := svc.ListBasesByType(context.Background(), baseID, "base"); err != nil { t.Fatalf("expected list bases by type forwarded") } if repo.lastBasesType != "base" { t.Fatalf("expected base type forwarded to list bases, got %q", repo.lastBasesType) } } func TestHemsDutyRosterService_PropagatesErrors(t *testing.T) { repo := &hemsDutyRosterRepoMock{ createErr: errors.New("create error"), updateErr: errors.New("update error"), deleteErr: errors.New("delete error"), getErr: errors.New("get error"), crewsErr: errors.New("crews error"), listErr: errors.New("list error"), basesErr: errors.New("bases error"), } svc := NewDutyRosterService(repo) if err := svc.Create(context.Background(), &dutyroster.DutyRoster{}, nil); err == nil { t.Fatalf("expected create error") } if err := svc.Update(context.Background(), &dutyroster.DutyRoster{}, nil); err == nil { t.Fatalf("expected update error") } if err := svc.Delete(context.Background(), []byte("id"), []byte("actor")); err == nil { t.Fatalf("expected delete error") } if _, err := svc.GetHeaderByID(context.Background(), []byte("id")); err == nil { t.Fatalf("expected get error") } if _, err := svc.GetCrewsByRosterIDs(context.Background(), [][]byte{[]byte("id")}); err == nil { t.Fatalf("expected crews error") } if _, err := svc.ListHeadersByRange(context.Background(), []byte("base"), time.Now(), time.Now()); err == nil { t.Fatalf("expected list error") } if _, err := svc.ListHEMSBases(context.Background(), []byte("base")); err == nil { t.Fatalf("expected bases error") } } func TestHemsDutyRosterService_NormalizesWritePayloadBeforeRepo(t *testing.T) { repo := &hemsDutyRosterRepoMock{} svc := NewDutyRosterService(repo) row := &dutyroster.DutyRoster{} crews := []dutyroster.DutyRosterCrew{ { RoleCode: "pilot", CrewType: "main", }, } if err := svc.Create(context.Background(), row, crews); err != nil { t.Fatalf("create: %v", err) } if repo.lastCreateRow == nil { t.Fatalf("expected create row forwarded") } if len(repo.insertedCrews) != 1 { t.Fatalf("expected 1 crew inserted") } if repo.insertedCrews[0].ShiftStart != "2000-01-01 00:00:00" || repo.insertedCrews[0].ShiftEnd != "2000-01-01 00:00:00" { t.Fatalf("expected crew shift normalized, got %q-%q", repo.insertedCrews[0].ShiftStart, repo.insertedCrews[0].ShiftEnd) } } func TestHemsDutyRosterService_NormalizeFilterShiftInput(t *testing.T) { if got := normalizeCrewShiftForFilter(""); got != "" { t.Fatalf("expected empty filter shift, got %q", got) } if got := normalizeCrewShiftForFilter("08:15"); got != "2000-01-01 08:15:00" { t.Fatalf("expected normalized filter shift, got %q", got) } } func TestDutyRosterDeletedCrewItems_IncludesName(t *testing.T) { rows := []dutyroster.DeletedCrewResult{ { ID: []byte("1234567890123456"), RosterID: []byte("abcdefabcdefabcd"), UserID: []byte("9999999999999999"), Name: "Pilot One", RoleCode: "pilot", CrewType: "main", }, } items := DutyRosterDeletedCrewItems(rows) if len(items) != 1 { t.Fatalf("expected 1 item, got %d", len(items)) } if got, _ := items[0]["name"].(string); got != "Pilot One" { t.Fatalf("expected deleted item name, got %#v", items[0]["name"]) } } type dutyRosterTxRepoMock struct { lockFn func(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) createHeaderFn func(ctx context.Context, row *dutyroster.DutyRoster) error findIDByDateFn func(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error) listActiveCrewsFn func(ctx context.Context, rosterID []byte) ([]dutyroster.DutyRosterCrew, error) insertCrewFn func(ctx context.Context, row *dutyroster.DutyRosterCrew) error crewExistsFn func(ctx context.Context, row dutyroster.DutyRosterCrew) (bool, error) softDeleteByUserFn func(ctx context.Context, rosterID []byte, roleCode, crewType string, userID []byte, deletedBy []byte) error softDeleteCrewsFn func(ctx context.Context, rosterID []byte, deletedBy []byte) error softDeleteForDateFn func(ctx context.Context, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, deletedBy []byte) error findIDsByDateFn func(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error) matchCrewByFilterFn func(ctx context.Context, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) softDeleteRowsFn func(ctx context.Context, rows []dutyroster.DeletedCrewResult, deletedBy []byte) error } func (m *dutyRosterTxRepoMock) GetRosterByID(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) { return nil, nil } func (m *dutyRosterTxRepoMock) LockRosterByID(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) { if m.lockFn != nil { return m.lockFn(ctx, id) } return &dutyroster.DutyRoster{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil } func (m *dutyRosterTxRepoMock) CreateHeader(ctx context.Context, row *dutyroster.DutyRoster) error { if m.createHeaderFn != nil { return m.createHeaderFn(ctx, row) } return nil } func (m *dutyRosterTxRepoMock) UpdateHeader(_ context.Context, _ *dutyroster.DutyRoster) error { return nil } func (m *dutyRosterTxRepoMock) SoftDeleteHeader(_ context.Context, _ []byte, _ []byte) error { return nil } func (m *dutyRosterTxRepoMock) SoftDeleteCrewsByRosterID(ctx context.Context, rosterID []byte, deletedBy []byte) error { if m.softDeleteCrewsFn != nil { return m.softDeleteCrewsFn(ctx, rosterID, deletedBy) } return nil } func (m *dutyRosterTxRepoMock) ListActiveCrewsByRosterID(ctx context.Context, rosterID []byte) ([]dutyroster.DutyRosterCrew, error) { if m.listActiveCrewsFn != nil { return m.listActiveCrewsFn(ctx, rosterID) } return nil, nil } func (m *dutyRosterTxRepoMock) InsertCrew(ctx context.Context, row *dutyroster.DutyRosterCrew) error { if m.insertCrewFn != nil { return m.insertCrewFn(ctx, row) } return nil } func (m *dutyRosterTxRepoMock) CrewExists(ctx context.Context, row dutyroster.DutyRosterCrew) (bool, error) { if m.crewExistsFn != nil { return m.crewExistsFn(ctx, row) } return false, nil } func (m *dutyRosterTxRepoMock) SoftDeleteCrewByUser(ctx context.Context, rosterID []byte, roleCode, crewType string, userID []byte, deletedBy []byte) error { if m.softDeleteByUserFn != nil { return m.softDeleteByUserFn(ctx, rosterID, roleCode, crewType, userID, deletedBy) } return nil } func (m *dutyRosterTxRepoMock) FindRosterIDByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error) { if m.findIDByDateFn != nil { return m.findIDByDateFn(ctx, baseID, dutyDate) } return nil, nil } func (m *dutyRosterTxRepoMock) FindRosterIDsByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error) { if m.findIDsByDateFn != nil { return m.findIDsByDateFn(ctx, baseID, dutyDate) } return [][]byte{uuidv7.MustBytes()}, nil } func (m *dutyRosterTxRepoMock) MatchCrewByFilter(ctx context.Context, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) { if m.matchCrewByFilterFn != nil { return m.matchCrewByFilterFn(ctx, rosterIDs, crew) } return []dutyroster.DeletedCrewResult{}, nil } func (m *dutyRosterTxRepoMock) SoftDeleteCrewRows(ctx context.Context, rows []dutyroster.DeletedCrewResult, deletedBy []byte) error { if m.softDeleteRowsFn != nil { return m.softDeleteRowsFn(ctx, rows, deletedBy) } return nil } func (m *dutyRosterTxRepoMock) SoftDeleteCrewForDate(ctx context.Context, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, deletedBy []byte) error { if m.softDeleteForDateFn != nil { return m.softDeleteForDateFn(ctx, baseID, dutyDate, crew, deletedBy) } return nil } func (m *dutyRosterTxRepoMock) HardDeleteHeader(_ context.Context, _ []byte) error { return nil } func (m *dutyRosterTxRepoMock) HardDeleteCrewsByRosterID(_ context.Context, _ []byte) error { return nil } type dutyRosterRepoWithTxMock struct { txRepo dutyroster.TxRepository inTxErr error shiftResult string shiftErr error } func (m *dutyRosterRepoWithTxMock) InTx(ctx context.Context, fn func(txRepo dutyroster.TxRepository) error) error { if m.inTxErr != nil { return m.inTxErr } return fn(m.txRepo) } func (m *dutyRosterRepoWithTxMock) GetBaseDefaultShift(_ context.Context, _ []byte, _ string) (string, error) { return m.shiftResult, m.shiftErr } func (m *dutyRosterRepoWithTxMock) GetHeaderByID(_ context.Context, _ []byte) (*dutyroster.HeaderView, error) { return nil, nil } func (m *dutyRosterRepoWithTxMock) GetHeaderByIDByBaseType(_ context.Context, _ []byte, _ string) (*dutyroster.HeaderView, error) { return nil, nil } func (m *dutyRosterRepoWithTxMock) GetCrewsByRosterIDs(_ context.Context, _ [][]byte) ([]dutyroster.CrewView, error) { return nil, nil } func (m *dutyRosterRepoWithTxMock) ListHeadersByRange(_ context.Context, _ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) { return nil, nil } func (m *dutyRosterRepoWithTxMock) ListHeadersByRangeByBaseType(_ context.Context, _ []byte, _, _ time.Time, _ string) ([]dutyroster.HeaderView, error) { return nil, nil } func (m *dutyRosterRepoWithTxMock) ListHEMSBases(_ context.Context, _ []byte) ([]dutyroster.BaseView, error) { return nil, nil } func (m *dutyRosterRepoWithTxMock) ListBasesByType(_ context.Context, _ []byte, _ string) ([]dutyroster.BaseView, error) { return nil, nil } func TestDutyRosterDeleteCrewsFlow(t *testing.T) { baseID := uuidv7.MustBytes() rosterID := uuidv7.MustBytes() deletedBy := uuidv7.MustBytes() rowA := dutyroster.DeletedCrewResult{ID: uuidv7.MustBytes(), RosterID: rosterID, UserID: uuidv7.MustBytes(), Name: "Pilot A", RoleCode: "pilot", CrewType: "main"} rowB := dutyroster.DeletedCrewResult{ID: uuidv7.MustBytes(), RosterID: rosterID, UserID: uuidv7.MustBytes(), Name: "Pilot B", RoleCode: "pilot", CrewType: "main"} deleteCalls := 0 tx := &dutyRosterTxRepoMock{ lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) { return &dutyroster.DutyRoster{ID: rosterID, BaseID: baseID, DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil }, findIDsByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([][]byte, error) { return [][]byte{rosterID}, nil }, matchCrewByFilterFn: func(_ context.Context, _ [][]byte, _ dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) { // include duplicate ID to cover de-dup collector. return []dutyroster.DeletedCrewResult{rowA, rowA, rowB}, nil }, softDeleteRowsFn: func(_ context.Context, rows []dutyroster.DeletedCrewResult, _ []byte) error { deleteCalls++ if len(rows) == 0 { t.Fatalf("expected rows for delete") } return nil }, } repo := &dutyRosterRepoWithTxMock{txRepo: tx} svc := NewDutyRosterService(repo) start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC) crews := []dutyroster.DutyRosterCrew{ { RoleCode: "pilot", CrewType: "main", UserID: rowA.UserID, DateStart: &start, DateEnd: &end, }, } out, err := svc.DeleteCrews(context.Background(), rosterID, crews, deletedBy) if err != nil { t.Fatalf("delete crews: %v", err) } if deleteCalls == 0 { t.Fatalf("expected soft delete called") } if len(out) != 2 { t.Fatalf("expected de-duplicated deleted rows = 2, got %d", len(out)) } } func TestDutyRosterDeleteCrews_Errors(t *testing.T) { rosterID := uuidv7.MustBytes() tx := &dutyRosterTxRepoMock{ lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) { return nil, errors.New("lock failed") }, } repo := &dutyRosterRepoWithTxMock{txRepo: tx} svc := NewDutyRosterService(repo) _, err := svc.DeleteCrews(context.Background(), rosterID, []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}}, uuidv7.MustBytes()) if err == nil { t.Fatalf("expected error") } } func TestDutyRosterResolveBaseDefaultShiftRangeBranches(t *testing.T) { svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}}) start, end, err := svc.ResolveBaseDefaultShiftRange(context.Background(), nil, "base") if err != nil || start != "" || end != "" { t.Fatalf("expected empty fallback shift, got %q-%q err=%v", start, end, err) } repoErr := &dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}, shiftErr: errors.New("db error")} svcErr := NewDutyRosterService(repoErr) _, _, err = svcErr.ResolveBaseDefaultShiftRange(context.Background(), uuidv7.MustBytes(), "base") if err == nil { t.Fatalf("expected error from repo") } repoRaw := &dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}, shiftResult: "start=07:00 end=19:00"} svcRaw := NewDutyRosterService(repoRaw) start, end, err = svcRaw.ResolveBaseDefaultShiftRange(context.Background(), uuidv7.MustBytes(), "base") if err != nil || start != "07:00:00" || end != "19:00:00" { t.Fatalf("expected parsed shift, got %q-%q err=%v", start, end, err) } } func TestDutyRosterParsingHelpers(t *testing.T) { // ParseDutyRosterShiftRange + token extraction s, e := ParseDutyRosterShiftRange("Shift 08:15 - 20:45") if s != "08:15:00" || e != "20:45:00" { t.Fatalf("unexpected parsed range: %q-%q", s, e) } s, e = ParseDutyRosterShiftRange("bad") if s != "" || e != "" { t.Fatalf("expected empty range on bad input") } // NormalizeDutyRosterClockInput out, errObj := NormalizeDutyRosterClockInput("09:30", "", "/x") if errObj != nil || out != "09:30:00" { t.Fatalf("expected normalized HH:MM, got out=%q err=%#v", out, errObj) } _, errObj = NormalizeDutyRosterClockInput("99:99", "", "/x") if errObj == nil { t.Fatalf("expected invalid time error") } // ParseDutyRosterDateRangeOrDefault now := time.Date(2026, 3, 18, 10, 0, 0, 0, time.UTC) from, to, errObj := ParseDutyRosterDateRangeOrDefault("", "", now) if errObj != nil || from.Format("2006-01-02") != "2026-03-01" || to.Format("2006-01-02") != "2026-03-31" { t.Fatalf("unexpected default range: %v %v err=%#v", from, to, errObj) } _, _, errObj = ParseDutyRosterDateRangeOrDefault("bad", "", now) if errObj == nil || errObj.Pointer != "/query/from" { t.Fatalf("expected invalid from") } _, _, errObj = ParseDutyRosterDateRangeOrDefault("", "bad", now) if errObj == nil || errObj.Pointer != "/query/to" { t.Fatalf("expected invalid to") } if _, err := ParseDutyRosterDateOnly("2026-03-20"); err != nil { t.Fatalf("ParseDutyRosterDateOnly: %v", err) } } func TestBuildDutyRosterDeleteCrewsBranches(t *testing.T) { actor := uuidv7.MustBytes() crews, errObj := BuildDutyRosterDeleteCrews([]byte("{}"), actor) if errObj != nil || crews != nil { t.Fatalf("expected nil crews on empty payload") } _, errObj = BuildDutyRosterDeleteCrews([]byte("{"), actor) if errObj == nil || errObj.Pointer != "/data" { t.Fatalf("expected invalid delete payload") } body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUID(t) + `","shift_date":{"date_start":"2026-03-21"}}]}}}}`) _, errObj = BuildDutyRosterDeleteCrews(body, actor) if errObj == nil || errObj.Detail != "shift_date.date_start and shift_date.date_end must be provided together" { t.Fatalf("expected shift_date pair validation, got %#v", errObj) } body = []byte(`{"data":{"attributes":{"roster_detail":{"other_person":[{"name":"Guest"}]}}}}`) crews, errObj = BuildDutyRosterDeleteCrews(body, actor) if errObj != nil || len(crews) != 1 { t.Fatalf("expected valid other_person delete crew, got crews=%d err=%#v", len(crews), errObj) } } func TestDutyRosterGroupingAndMetadataHelpers(t *testing.T) { rosterID := uuidv7.MustBytes() baseID := uuidv7.MustBytes() groups := GroupRostersByBase([]responsedto.DutyRosterResource{ { ID: "r1", Attributes: responsedto.DutyRosterAttributes{ Bases: responsedto.DutyRosterBaseInfo{ID: "b1", BaseName: "Base 1"}, }, }, { ID: "r2", Attributes: responsedto.DutyRosterAttributes{ Bases: responsedto.DutyRosterBaseInfo{ID: "b1", BaseName: "Base 1"}, }, }, }) if len(groups) != 1 || len(groups[0].Attributes.Rosters) != 2 { t.Fatalf("unexpected grouped rosters: %#v", groups) } merged := MergeEmptyBaseGroups(groups, []dutyroster.BaseView{{ID: baseID, BaseName: "Base 2"}}) if len(merged) != 2 { t.Fatalf("expected appended empty base group") } meta := DutyRosterCrewAuditMetadata(rosterID, "hems", []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}}) if meta["base_type"] != "hems" { t.Fatalf("unexpected metadata: %#v", meta) } if roleBucket("air_rescuer") != "rescuer" || roleBucket("flight_assistant") != "helper" || roleBucket("x") != "other_person" { t.Fatalf("unexpected role bucket mapping") } if firstNonEmpty("a", "b") != "a" || firstNonEmpty("", "b") != "b" { t.Fatalf("unexpected firstNonEmpty") } if p := boolPtrIfTrue(true); p == nil || !*p { t.Fatalf("unexpected bool ptr") } if p := boolPtrIfTrue(false); p != nil { t.Fatalf("expected nil ptr for false") } } func TestDutyRosterResourceMapping(t *testing.T) { header := dutyroster.HeaderView{ ID: uuidv7.MustBytes(), BaseID: uuidv7.MustBytes(), BaseName: "Base A", BaseAbbreviation: "BA", DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), CreatedBy: uuidv7.MustBytes(), CreatedAt: time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 3, 20, 2, 0, 0, 0, time.UTC), } crews := []dutyroster.CrewView{ { ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Pilot One", Role: "pilot", RoleCode: "pilot", CrewType: "additional", FlightInstructor: true, }, { ID: uuidv7.MustBytes(), Name: "Guest", Role: "other", RoleCode: "other_person", }, } got := DutyRosterResource(context.Background(), header, crews) if got.Type != "duty_roster" || got.Attributes.Bases.BaseName != "Base A" { t.Fatalf("unexpected duty roster resource: %#v", got) } if len(got.Attributes.Detail.Pilot) != 1 || len(got.Attributes.Detail.OtherPerson) != 1 { t.Fatalf("unexpected role buckets: %#v", got.Attributes.Detail) } if got.Attributes.CreatedBy == "" { t.Fatalf("expected created_by on duty roster resource") } } func TestBuildDutyRosterCrewRows_CoreBranches(t *testing.T) { actor := uuidv7.MustBytes() pilotID := mustUUID(t) // invalid crew id _, errObj := BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{ Pilot: []requestdto.DutyRosterPilotCrewInput{{ID: "bad"}}, }, actor) if errObj == nil || errObj.Detail != "crew id is invalid UUID" { t.Fatalf("expected invalid id error, got %#v", errObj) } // success with pilot additional flags + other_person rows, errObj := BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{ Pilot: []requestdto.DutyRosterPilotCrewInput{{ ID: pilotID, CrewType: "additional", FlightInstructor: true, }}, OtherPerson: []requestdto.DutyRosterOtherPersonInput{{ Name: "Guest", }}, }, actor) if errObj != nil || len(rows) != 2 { t.Fatalf("expected success rows, got err=%#v len=%d", errObj, len(rows)) } if !rows[0].FlightInstructor { t.Fatalf("expected pilot additional flag set") } if rows[1].RoleCode != "other_person" || rows[1].DateStart != nil || rows[1].ShiftStart != "" { t.Fatalf("expected other_person shift/date stripped") } } func TestDutyRosterService_EnsureRosterForDateBranches(t *testing.T) { svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}}) source := &dutyroster.DutyRoster{ BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), CreatedBy: uuidv7.MustBytes(), UpdatedBy: uuidv7.MustBytes(), } target := time.Date(2026, 3, 21, 10, 0, 0, 0, time.FixedZone("X", 7*3600)) t.Run("existing roster", func(t *testing.T) { existingID := uuidv7.MustBytes() locked := 0 tx := &dutyRosterTxRepoMock{ findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return existingID, nil }, lockFn: func(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) { locked++ return &dutyroster.DutyRoster{ID: id}, nil }, } got, err := svc.ensureRosterForDate(context.Background(), tx, source, target) if err != nil || string(got) != string(existingID) || locked != 1 { t.Fatalf("expected existing roster path, got id=%x lock=%d err=%v", got, locked, err) } }) t.Run("create header", func(t *testing.T) { tx := &dutyRosterTxRepoMock{ createHeaderFn: func(_ context.Context, row *dutyroster.DutyRoster) error { row.ID = uuidv7.MustBytes() return nil }, } got, err := svc.ensureRosterForDate(context.Background(), tx, source, target) if err != nil || len(got) == 0 { t.Fatalf("expected created roster id, got %x err=%v", got, err) } }) t.Run("concurrent create fallback winner", func(t *testing.T) { winner := uuidv7.MustBytes() locked := 0 tx := &dutyRosterTxRepoMock{ createHeaderFn: func(_ context.Context, _ *dutyroster.DutyRoster) error { return errors.New("duplicate") }, findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return winner, nil }, lockFn: func(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) { locked++ return &dutyroster.DutyRoster{ID: id}, nil }, } got, err := svc.ensureRosterForDate(context.Background(), tx, source, target) if err != nil || string(got) != string(winner) || locked != 1 { t.Fatalf("expected winner fallback, got id=%x lock=%d err=%v", got, locked, err) } }) t.Run("existing roster lock error", func(t *testing.T) { existingID := uuidv7.MustBytes() tx := &dutyRosterTxRepoMock{ findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return existingID, nil }, lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) { return nil, errors.New("lock failed") }, } _, err := svc.ensureRosterForDate(context.Background(), tx, source, target) if err == nil { t.Fatalf("expected lock error") } }) t.Run("create error and fetch error", func(t *testing.T) { tx := &dutyRosterTxRepoMock{ createHeaderFn: func(_ context.Context, _ *dutyroster.DutyRoster) error { return errors.New("duplicate") }, findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return nil, errors.New("fetch failed") }, } _, err := svc.ensureRosterForDate(context.Background(), tx, source, target) if err == nil || !strings.Contains(err.Error(), "fetch failed") { t.Fatalf("expected fetch error after create fail, got %v", err) } }) t.Run("create error and no winner id", func(t *testing.T) { tx := &dutyRosterTxRepoMock{ createHeaderFn: func(_ context.Context, _ *dutyroster.DutyRoster) error { return errors.New("duplicate") }, findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return nil, nil }, } _, err := svc.ensureRosterForDate(context.Background(), tx, source, target) if err == nil || !strings.Contains(err.Error(), "duplicate") { t.Fatalf("expected original create error, got %v", err) } }) } func TestDutyRosterService_AddRemoveRangeAssignmentsAndHelpers(t *testing.T) { baseID := uuidv7.MustBytes() row := &dutyroster.DutyRoster{ BaseID: baseID, DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), UpdatedBy: uuidv7.MustBytes(), CreatedBy: uuidv7.MustBytes(), } userID := uuidv7.MustBytes() start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC) crews := []dutyroster.DutyRosterCrew{{ RoleCode: "pilot", CrewType: "main", UserID: userID, DateStart: &start, DateEnd: &end, }} t.Run("add range", func(t *testing.T) { inserted := 0 deletedByUser := 0 callsByDate := map[string]int{} existingRoster := uuidv7.MustBytes() newRoster := uuidv7.MustBytes() tx := &dutyRosterTxRepoMock{ findIDByDateFn: func(_ context.Context, _ []byte, dutyDate time.Time) ([]byte, error) { d := dutyDate.UTC().Format("2006-01-02") callsByDate[d]++ if d == "2026-03-21" { return existingRoster, nil } return nil, nil }, lockFn: func(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) { return &dutyroster.DutyRoster{ID: id}, nil }, createHeaderFn: func(_ context.Context, r *dutyroster.DutyRoster) error { r.ID = append([]byte(nil), newRoster...) return nil }, crewExistsFn: func(_ context.Context, row dutyroster.DutyRosterCrew) (bool, error) { // skip insert for existing roster, insert for newly-created roster. return string(row.RosterID) == string(existingRoster), nil }, insertCrewFn: func(_ context.Context, _ *dutyroster.DutyRosterCrew) error { inserted++ return nil }, softDeleteByUserFn: func(_ context.Context, _ []byte, _, _ string, _ []byte, _ []byte) error { deletedByUser++ return nil }, } svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx}) if err := svc.addRangeAssignments(context.Background(), tx, row, crews); err != nil { t.Fatalf("add range assignments: %v", err) } if inserted != 1 || deletedByUser != 2 || callsByDate["2026-03-21"] == 0 || callsByDate["2026-03-22"] == 0 { t.Fatalf("unexpected add-range effects inserted=%d deletedByUser=%d calls=%v", inserted, deletedByUser, callsByDate) } }) t.Run("remove range", func(t *testing.T) { deletedDates := []string{} tx := &dutyRosterTxRepoMock{ softDeleteForDateFn: func(_ context.Context, _ []byte, dutyDate time.Time, _ dutyroster.DutyRosterCrew, _ []byte) error { deletedDates = append(deletedDates, dutyDate.UTC().Format("2006-01-02")) return nil }, } svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx}) newEnd := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC) newCrews := []dutyroster.DutyRosterCrew{{ RoleCode: "pilot", CrewType: "main", UserID: userID, DateStart: &start, DateEnd: &newEnd, }} if err := svc.removeRangeAssignments(context.Background(), tx, row, crews, newCrews); err != nil { t.Fatalf("remove range assignments: %v", err) } if len(deletedDates) != 1 || deletedDates[0] != "2026-03-22" { t.Fatalf("expected delete only out-of-range day, got %v", deletedDates) } }) parsed := collectRangeAssignments([]dutyroster.DutyRosterCrew{ {RoleCode: "other_person", DateStart: &start, DateEnd: &end}, {RoleCode: "pilot", DateStart: &end, DateEnd: &start}, {RoleCode: "pilot", DateStart: &start, DateEnd: &end, UserID: userID, CrewType: "main"}, }) if len(parsed) != 1 { t.Fatalf("expected one valid range assignment, got %d", len(parsed)) } if assignmentKey(parsed[0].crew) == "" { t.Fatalf("expected non-empty assignment key") } if replaceByUserKey(nil, "pilot", "main", userID) != "" || replaceByUserKey(uuidv7.MustBytes(), "pilot", "main", nil) != "" { t.Fatalf("expected empty replace key for missing ids") } if !isRoleRangeAssignable("pilot") || isRoleRangeAssignable("other_person") { t.Fatalf("unexpected range-assignable role mapping") } if len(expandDateRange(time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC))) != 0 { t.Fatalf("expected empty range when end before start") } } func TestDutyRosterService_CRUDBranchesWithTx(t *testing.T) { row := &dutyroster.DutyRoster{ ID: uuidv7.MustBytes(), BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), UpdatedBy: uuidv7.MustBytes(), CreatedBy: uuidv7.MustBytes(), } crews := []dutyroster.DutyRosterCrew{{ RoleCode: "pilot", CrewType: "main", UserID: uuidv7.MustBytes(), }} t.Run("create inTx error", func(t *testing.T) { svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}, inTxErr: errors.New("tx failed")}) if err := svc.Create(context.Background(), row, crews); err == nil { t.Fatalf("expected create tx error") } }) t.Run("update with no crews", func(t *testing.T) { tx := &dutyRosterTxRepoMock{ lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) { return &dutyroster.DutyRoster{ID: row.ID, BaseID: row.BaseID, DutyDate: row.DutyDate}, nil }, } svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx}) if err := svc.Update(context.Background(), row, nil); err != nil { t.Fatalf("expected update without crews success, got %v", err) } }) t.Run("delete lock error", func(t *testing.T) { tx := &dutyRosterTxRepoMock{ lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) { return nil, errors.New("lock failed") }, } svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx}) if err := svc.Delete(context.Background(), row.ID, row.UpdatedBy); err == nil { t.Fatalf("expected delete lock error") } }) } func TestDutyRosterService_ParsingAndValidationExtraBranches(t *testing.T) { // Validate delete shift_date branches start := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) if errObj := validateDutyRosterDeleteCrews([]dutyroster.DutyRosterCrew{{RoleCode: "pilot", DateStart: &start}}); errObj == nil { t.Fatalf("expected start/end pair validation error") } if errObj := validateDutyRosterDeleteCrews([]dutyroster.DutyRosterCrew{{RoleCode: "pilot", DateStart: &start, DateEnd: &end}}); errObj == nil { t.Fatalf("expected end >= start validation error") } if errObj := validateDutyRosterDeleteCrews([]dutyroster.DutyRosterCrew{{RoleCode: "other_person", DateStart: &start}}); errObj != nil { t.Fatalf("other_person should skip shift_date validation, got %#v", errObj) } if got := normalizeClockOrDefaultForCrew("", "07:00:00"); got != "07:00:00" { t.Fatalf("expected default clock, got %q", got) } if got, errObj := normalizeClockInputForCrew("bad", "", "/p"); errObj == nil || got != "" { t.Fatalf("expected invalid clock error") } // Parse shift range single token + invalid second token s, e := ParseDutyRosterShiftRange("start 09:15") if s != "09:15:00" || e != "09:15:00" { t.Fatalf("expected single token shift, got %q-%q", s, e) } s, e = ParseDutyRosterShiftRange("09:00 xx:yy") if s != "09:00:00" || e != "09:00:00" { t.Fatalf("expected invalid second token fallback to start, got %q-%q", s, e) } if len(extractClockTokensForShift("")) != 0 { t.Fatalf("expected no tokens for empty shift text") } now := time.Date(2026, 3, 18, 10, 0, 0, 0, time.UTC) from, to, errObj := ParseDutyRosterDateRangeOrDefault("2026-03-10", "", now) if errObj != nil || from.Format("2006-01-02") != "2026-03-10" || to.Format("2006-01-02") != "2026-03-31" { t.Fatalf("unexpected only-from parse result") } from, to, errObj = ParseDutyRosterDateRangeOrDefault("", "2026-03-20", now) if errObj != nil || from.Format("2006-01-02") != "2026-03-01" || to.Format("2006-01-02") != "2026-03-20" { t.Fatalf("unexpected only-to parse result") } } func TestDutyRosterService_BuildDeleteCrewsAndUUIDExtraBranches(t *testing.T) { actor := uuidv7.MustBytes() // nil detail branch rows, errObj := BuildDutyRosterDeleteCrews([]byte(`{"data":{"attributes":{}}}`), actor) if errObj != nil || rows != nil { t.Fatalf("expected nil rows when detail omitted, got rows=%v err=%#v", rows, errObj) } // invalid payload branch if _, errObj = BuildDutyRosterDeleteCrews([]byte(`not-json`), actor); errObj == nil { t.Fatalf("expected invalid delete payload error") } // uuid conversion error branch if got := uuidStringOrEmpty(context.Background(), []byte{1, 2, 3}); got != "" { t.Fatalf("expected empty string for invalid uuid bytes, got %q", got) } if out, errObj := normalizeClockInputForCrew("", "", "/p"); errObj != nil || out != "" { t.Fatalf("expected empty clock allowed, got out=%q err=%#v", out, errObj) } if s, e := ParseDutyRosterShiftRange("99:99 09:00"); s != "" || e != "09:00:00" { t.Fatalf("expected invalid first token ignored by parse, got %q-%q", s, e) } if s, e := ParseDutyRosterShiftRange("09:00 99:99"); s != "09:00:00" || e != "" { t.Fatalf("expected invalid second token cleared, got %q-%q", s, e) } svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}}) rows2, err := svc.DeleteCrews(context.Background(), uuidv7.MustBytes(), nil, uuidv7.MustBytes()) if err != nil || len(rows2) != 0 { t.Fatalf("expected empty delete crews shortcut, got rows=%v err=%v", rows2, err) } } func TestDutyRosterService_GroupMergeExtraBranches(t *testing.T) { empty := GroupRostersByBase(nil) merged := MergeEmptyBaseGroups(empty, []dutyroster.BaseView{{ID: uuidv7.MustBytes(), BaseName: "H1", BaseAbbreviation: "H1"}}) if len(merged) != 1 || len(merged[0].Attributes.Rosters) != 0 { t.Fatalf("expected one empty hems base group, got %#v", merged) } } func TestDutyRosterResource_AllRoleDetails(t *testing.T) { header := dutyroster.HeaderView{ ID: uuidv7.MustBytes(), BaseID: uuidv7.MustBytes(), BaseName: "Base B", BaseAbbreviation: "BB", DutyDate: time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 3, 21, 1, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 3, 21, 2, 0, 0, 0, time.UTC), } crews := []dutyroster.CrewView{ {ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Pilot Main", RoleCode: "pilot", CrewType: "main", Supervisor: true}, {ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Pilot Main", RoleCode: "pilot", CrewType: "main", Supervisor: true}, {ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Doctor", RoleCode: "doctor"}, {ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Rescuer", RoleCode: "rescuer"}, {ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Helper", RoleCode: "helper"}, {ID: uuidv7.MustBytes(), Name: "Guest", RoleCode: "other_person", Email: "guest@example.com"}, {ID: uuidv7.MustBytes(), Name: "Unknown", RoleCode: "unknown"}, } got := DutyRosterResource(context.Background(), header, crews) if got.Attributes.ShiftTime.ShiftStart != "" || got.Attributes.ShiftTime.ShiftEnd != "" { t.Fatalf("expected empty header shift in response when source empty") } if len(got.Attributes.Detail.Pilot) != 2 || len(got.Attributes.Detail.Doctor) != 1 || len(got.Attributes.Detail.Rescuer) != 1 || len(got.Attributes.Detail.Helper) != 1 { t.Fatalf("expected all operational buckets mapped, got %#v", got.Attributes.Detail) } if len(got.Attributes.Detail.OtherPerson) != 2 { t.Fatalf("expected unknown role falls into other_person bucket, got %d", len(got.Attributes.Detail.OtherPerson)) } } func mustUUID(t *testing.T) string { t.Helper() v, err := uuidv7.BytesToString(uuidv7.MustBytes()) if err != nil { t.Fatalf("uuid to string: %v", err) } return v }