1223 lines
45 KiB
Go
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
|
|
}
|