Files
fm_be/internal/service/duty_roster_service_test.go
2026-07-16 22:16:45 +07:00

1223 lines
45 KiB
Go

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
}