package service import ( "context" "errors" "strings" "testing" "time" afterflightinspection "wucher/internal/domain/after_flight_inspection" basedomain "wucher/internal/domain/base" flightdomain "wucher/internal/domain/flight" flightdata "wucher/internal/domain/flight_data" "wucher/internal/domain/mission" reserveac "wucher/internal/domain/reserve_ac" takeoverdomain "wucher/internal/domain/takeover" "wucher/internal/shared/pkg/util" ) type missionRepoMock struct { createFn func(context.Context, *mission.Mission) error findCategoryByCodeFn func(context.Context, string) (*mission.MissionCategory, error) updateFlightDataIDByIDFn func(context.Context, []byte, []byte, []byte) error deleteByIDFn func(context.Context, []byte, []byte) error deleteByFlightIDFn func(ctx context.Context, flightID []byte, deletedBy []byte) error maxCodeSeqFn func() (int, error) } func (m *missionRepoMock) WithTransaction(ctx context.Context, fn func(context.Context) error) error { return fn(ctx) } func (m *missionRepoMock) Create(ctx context.Context, row *mission.Mission) error { if m.createFn != nil { return m.createFn(ctx, row) } return nil } func (m *missionRepoMock) CreateCategory(context.Context, *mission.MissionCategory) error { return nil } func (m *missionRepoMock) CreatePlaceholder(context.Context, *flightdata.FlightData) error { return nil } func (m *missionRepoMock) UpdateFlightDataIDByID(ctx context.Context, missionID, flightDataID, updatedBy []byte) error { if m.updateFlightDataIDByIDFn != nil { return m.updateFlightDataIDByIDFn(ctx, missionID, flightDataID, updatedBy) } return nil } func (m *missionRepoMock) UpdateByID(context.Context, []byte, string, []byte, []byte, string, []byte) error { return nil } func (m *missionRepoMock) AttachFile(context.Context, []byte, []byte) error { return nil } func (m *missionRepoMock) DetachFile(context.Context, []byte, []byte) error { return nil } func (m *missionRepoMock) DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error { if m.deleteByIDFn != nil { return m.deleteByIDFn(ctx, missionID, deletedBy) } return nil } func (m *missionRepoMock) DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error { if m.deleteByFlightIDFn != nil { return m.deleteByFlightIDFn(ctx, flightID, deletedBy) } return nil } func (m *missionRepoMock) ListByFlightID(context.Context, []byte) ([]mission.Mission, error) { return nil, nil } func (m *missionRepoMock) ListByFlightIDs(context.Context, [][]byte) ([]mission.Mission, error) { return nil, nil } func (m *missionRepoMock) GetByID(context.Context, []byte) (*mission.Mission, error) { return nil, nil } func (m *missionRepoMock) GetByFlightID(context.Context, []byte) (*mission.Mission, error) { return nil, nil } func (m *missionRepoMock) List(context.Context, string, string, []byte, string, int, int) ([]mission.Mission, int64, error) { return nil, 0, nil } func (m *missionRepoMock) ListDatatable(context.Context, mission.ListFilter, int, int) ([]mission.Mission, int64, int64, error) { return nil, 0, 0, nil } func (m *missionRepoMock) ListCategoriesWithSubCategories(context.Context) ([]mission.MissionCategory, error) { return nil, nil } func (m *missionRepoMock) FindCategoryByCode(ctx context.Context, code string) (*mission.MissionCategory, error) { if m.findCategoryByCodeFn != nil { return m.findCategoryByCodeFn(ctx, code) } return nil, nil } func (m *missionRepoMock) SubCategoryBelongsToCategory(context.Context, []byte, []byte) (bool, error) { return false, nil } func (m *missionRepoMock) MaxCodeSeqByPrefix(context.Context, string) (int, error) { if m.maxCodeSeqFn != nil { return m.maxCodeSeqFn() } return 0, nil } type flightDataSvcMockForMission struct { createPlaceholderFn func(context.Context, *flightdata.FlightData) error } type afterFlightInspectionSvcMockForMission struct { getByFlightInspectionIDFn func(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error) } type flightSvcMockForMission struct { getByIDFn func(context.Context, []byte) (*flightdomain.Flight, error) } func (m *flightSvcMockForMission) Create(context.Context, *flightdomain.Flight) error { return nil } func (m *flightSvcMockForMission) Update(context.Context, *flightdomain.Flight) error { return nil } func (m *flightSvcMockForMission) Delete(context.Context, []byte, []byte) error { return nil } func (m *flightSvcMockForMission) GetByID(ctx context.Context, id []byte) (*flightdomain.Flight, error) { if m.getByIDFn != nil { return m.getByIDFn(ctx, id) } return nil, nil } func (m *flightSvcMockForMission) ListByUserID(context.Context, []byte, int, int) ([]flightdomain.Flight, int64, error) { return nil, 0, nil } func (m *flightSvcMockForMission) ListByCreatedBy(context.Context, []byte, string, string, string, int, int) ([]flightdomain.Flight, int64, error) { return nil, 0, nil } func (m *flightSvcMockForMission) GetByReserveAcID(context.Context, []byte) (*flightdomain.Flight, error) { return nil, nil } func (m *flightSvcMockForMission) ListByReserveAcID(context.Context, []byte) ([]flightdomain.Flight, error) { return nil, nil } func (m *flightSvcMockForMission) GetByTakeoverAcID(context.Context, []byte) (*flightdomain.Flight, error) { return nil, nil } func (m *flightSvcMockForMission) ListByTakeoverAcIDs(context.Context, [][]byte) ([]flightdomain.Flight, error) { return nil, nil } func (m *flightSvcMockForMission) GetByDutyRosterID(context.Context, []byte) (*flightdomain.Flight, error) { return nil, nil } func (m *flightSvcMockForMission) ListByDutyRosterIDs(context.Context, [][]byte) ([]flightdomain.Flight, error) { return nil, nil } func (m *flightSvcMockForMission) List(context.Context, string, string, string, int, int) ([]flightdomain.Flight, int64, error) { return nil, 0, nil } func (m *flightDataSvcMockForMission) Create(context.Context, *flightdata.FlightData) error { return nil } func (m *flightDataSvcMockForMission) CreatePlaceholder(ctx context.Context, row *flightdata.FlightData) error { if m.createPlaceholderFn != nil { return m.createPlaceholderFn(ctx, row) } return nil } func (m *flightDataSvcMockForMission) Update(context.Context, *flightdata.FlightData) error { return nil } func (m *flightDataSvcMockForMission) Delete(context.Context, []byte, []byte) error { return nil } func (m *flightDataSvcMockForMission) GetByID(context.Context, []byte) (*flightdata.FlightData, error) { return nil, nil } func (m *flightDataSvcMockForMission) GetByFlightID(context.Context, []byte) (*flightdata.FlightData, error) { return nil, nil } func (m *flightDataSvcMockForMission) GetByMissionID(context.Context, []byte) (*flightdata.FlightData, error) { return nil, nil } func (m *flightDataSvcMockForMission) ListByMissionID(context.Context, []byte) ([]flightdata.FlightData, error) { return nil, nil } func (m *flightDataSvcMockForMission) List(context.Context, string, []byte, []byte, int, int) ([]flightdata.FlightData, int64, error) { return nil, 0, nil } func (m *flightDataSvcMockForMission) GetSPODetailsByFlightDataID(context.Context, []byte) (*flightdata.SPODetails, error) { return nil, nil } func (m *flightDataSvcMockForMission) UpsertSPODetails(context.Context, []byte, *flightdata.SPOUpsertData) error { return nil } func (m *afterFlightInspectionSvcMockForMission) Upsert(context.Context, []byte, *afterflightinspection.UpsertRequest) (*afterflightinspection.AfterFlightInspection, error) { return nil, nil } func (m *afterFlightInspectionSvcMockForMission) GetByFlightInspectionID(ctx context.Context, id []byte) (*afterflightinspection.AfterFlightInspection, error) { if m.getByFlightInspectionIDFn != nil { return m.getByFlightInspectionIDFn(ctx, id) } return nil, nil } func (m *afterFlightInspectionSvcMockForMission) DeleteByFlightInspectionID(context.Context, []byte) error { return nil } func TestMissionServiceCreateDoesNotAutoCreateFlightData(t *testing.T) { var createdPlaceholder *flightdata.FlightData var updatedFlightDataID bool flightID := []byte("flight-id-123456") date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) baseRow := &basedomain.Base{ ID: []byte("base-id-12345678"), BaseName: "Zurich", Latitude: 47.3769, Longitude: 8.5417, UTC: "Europe/Zurich", OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{{ DateStart: &date, DateEnd: &date, StartTimeType: basedomain.ShiftTimeTypeFixed, EndTimeType: basedomain.ShiftTimeTypeFixed, ShiftStart: "09:00:00", ShiftEnd: "17:00:00", }}, } svc := NewMissionService( &missionRepoMock{ findCategoryByCodeFn: func(context.Context, string) (*mission.MissionCategory, error) { return &mission.MissionCategory{ID: []byte("category-id-1234"), CodeType: "SPO", TypeName: "SPO"}, nil }, updateFlightDataIDByIDFn: func(context.Context, []byte, []byte, []byte) error { updatedFlightDataID = true return nil }, maxCodeSeqFn: func() (int, error) { return 4, nil }, createFn: func(_ context.Context, row *mission.Mission) error { if row.StartTime == "" || row.EndTime == "" { t.Fatalf("expected resolved mission times on create, got start=%q end=%q", row.StartTime, row.EndTime) } if !strings.HasPrefix(row.Code, "SPO-") || !strings.HasSuffix(row.Code, "-5") { t.Fatalf("expected generated code SPO--5, got %q", row.Code) } return nil }, }, &flightSvcMockForMission{ getByIDFn: func(context.Context, []byte) (*flightdomain.Flight, error) { return &flightdomain.Flight{ ID: flightID, Date: date, Takeover: &takeoverdomain.TakeoverAc{ Base: baseRow, }, }, nil }, }, &flightDataSvcMockForMission{ createPlaceholderFn: func(_ context.Context, row *flightdata.FlightData) error { row.ID = []byte("flight-data-id-123") createdPlaceholder = row return nil }, }, ) row := &mission.Mission{ FlightID: flightID, Type: "spo", CreatedBy: []byte("creator-id-12345"), UpdatedBy: []byte("creator-id-12345"), } if err := svc.Create(context.Background(), row); err != nil { t.Fatalf("Create returned error: %v", err) } // Mission create must NOT auto-create any flight data anymore. if createdPlaceholder != nil { t.Fatal("expected no placeholder flight data to be created on mission create") } if updatedFlightDataID { t.Fatal("expected mission flight_data_id not to be touched") } if len(row.FlightDataID) != 0 { t.Fatalf("expected mission flight_data_id to stay empty, got %x", row.FlightDataID) } } func TestMissionServiceCreateBlockedWhenAfterFlightInspectionExists(t *testing.T) { flightID := []byte("flight-id-123456") inspectionID := []byte("inspection-id-12") date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) baseRow := &basedomain.Base{ ID: []byte("base-id-12345678"), BaseName: "Zurich", Latitude: 47.3769, Longitude: 8.5417, UTC: "Europe/Zurich", OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{{ DateStart: &date, DateEnd: &date, StartTimeType: basedomain.ShiftTimeTypeFixed, EndTimeType: basedomain.ShiftTimeTypeFixed, ShiftStart: "09:00:00", ShiftEnd: "17:00:00", }}, } repo := &missionRepoMock{ findCategoryByCodeFn: func(context.Context, string) (*mission.MissionCategory, error) { return &mission.MissionCategory{ID: []byte("category-id-1234"), CodeType: "SPO", TypeName: "SPO"}, nil }, createFn: func(context.Context, *mission.Mission) error { t.Fatal("mission create should not be called when after flight inspection exists") return nil }, } svc := NewMissionService( repo, &flightSvcMockForMission{ getByIDFn: func(context.Context, []byte) (*flightdomain.Flight, error) { return &flightdomain.Flight{ ID: flightID, Date: date, Takeover: &takeoverdomain.TakeoverAc{ Base: baseRow, ReserveAc: &reserveac.ReserveAc{InspectionID: inspectionID}, }, }, nil }, }, &afterFlightInspectionSvcMockForMission{ getByFlightInspectionIDFn: func(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error) { return &afterflightinspection.AfterFlightInspection{ID: []byte("after-flight-123")}, nil }, }, ) err := svc.Create(context.Background(), &mission.Mission{ FlightID: flightID, Type: "SPO", CreatedBy: []byte("creator-12345678"), UpdatedBy: []byte("creator-12345678"), }) if !errors.Is(err, mission.ErrLockedAfterFlightInspection) { t.Fatalf("expected after-flight inspection lock error, got %v", err) } } func TestMissionServiceCreateResolvesMissionTimes(t *testing.T) { flightID := []byte("flight-id-123456") date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) cases := []struct { name string startType string endType string startClock string endClock string useDefaultShift bool defaultStart string defaultEnd string wantStart string wantEnd string }{ { name: "fixed", startType: basedomain.ShiftTimeTypeFixed, endType: basedomain.ShiftTimeTypeFixed, startClock: "06:00:00", endClock: "21:00:00", wantStart: "06:00", wantEnd: "21:00", }, { name: "bmct and ecet", startType: basedomain.ShiftTimeTypeBMCT, endType: basedomain.ShiftTimeTypeECET, wantStart: "04:49", wantEnd: "22:05", }, { name: "mixed fixed start", startType: basedomain.ShiftTimeTypeFixed, endType: basedomain.ShiftTimeTypeECET, startClock: "23:59:00", wantStart: "23:59", wantEnd: "22:05", }, { name: "mixed fixed end", startType: basedomain.ShiftTimeTypeBMCT, endType: basedomain.ShiftTimeTypeFixed, endClock: "23:59:00", wantStart: "04:49", wantEnd: "23:59", }, { name: "fallback to default shift when operational schedule missing", useDefaultShift: true, wantStart: "06:00", wantEnd: "21:00", }, { name: "fallback accepts datetime default shifts", useDefaultShift: true, defaultStart: "2000-01-01 06:00:00", defaultEnd: "2000-01-01 21:00:00", wantStart: "06:00", wantEnd: "21:00", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { baseRow := &basedomain.Base{ ID: []byte("base-id-12345678"), BaseName: "Zurich", Latitude: 47.3769, Longitude: 8.5417, UTC: "Europe/Zurich", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", } if tc.defaultStart != "" { baseRow.DefaultShiftStart = tc.defaultStart } if tc.defaultEnd != "" { baseRow.DefaultShiftEnd = tc.defaultEnd } if !tc.useDefaultShift { baseRow.OperationalShiftTimes = []basedomain.BaseOperationalShiftTime{{ DateStart: &date, DateEnd: &date, StartTimeType: tc.startType, EndTimeType: tc.endType, ShiftStart: tc.startClock, ShiftEnd: tc.endClock, }} } var gotStart, gotEnd string svc := NewMissionService( &missionRepoMock{ findCategoryByCodeFn: func(context.Context, string) (*mission.MissionCategory, error) { return &mission.MissionCategory{ID: []byte("category-id-1234"), CodeType: "SPO", TypeName: "SPO"}, nil }, createFn: func(_ context.Context, row *mission.Mission) error { gotStart = row.StartTime gotEnd = row.EndTime return nil }, }, &flightSvcMockForMission{ getByIDFn: func(context.Context, []byte) (*flightdomain.Flight, error) { return &flightdomain.Flight{ ID: flightID, Date: date, Takeover: &takeoverdomain.TakeoverAc{ Base: baseRow, }, }, nil }, }, ) row := &mission.Mission{FlightID: flightID, Type: "SPO"} if err := svc.Create(context.Background(), row); err != nil { t.Fatalf("Create returned error: %v", err) } if gotStart != tc.wantStart || gotEnd != tc.wantEnd { t.Fatalf("unexpected resolved times: got %s/%s want %s/%s", gotStart, gotEnd, tc.wantStart, tc.wantEnd) } }) } } func TestMissionServiceDeleteByFlightIDRequiresFlightID(t *testing.T) { svc := NewMissionService(&missionRepoMock{}) err := svc.DeleteByFlightID(context.Background(), nil, nil) if !errors.Is(err, mission.ErrFlightIDRequired) { t.Fatalf("expected ErrFlightIDRequired, got %v", err) } } func TestMissionServiceDeleteByFlightIDDelegatesToRepo(t *testing.T) { wantFlightID := []byte("1234567890123456") wantDeletedBy := []byte("6543210987654321") called := false svc := NewMissionService(&missionRepoMock{ deleteByFlightIDFn: func(_ context.Context, flightID []byte, deletedBy []byte) error { called = true if string(flightID) != string(wantFlightID) { t.Fatalf("unexpected flightID: %x", flightID) } if string(deletedBy) != string(wantDeletedBy) { t.Fatalf("unexpected deletedBy: %x", deletedBy) } return nil }, }) if err := svc.DeleteByFlightID(context.Background(), wantFlightID, wantDeletedBy); err != nil { t.Fatalf("DeleteByFlightID returned error: %v", err) } if !called { t.Fatal("expected repository DeleteByFlightID to be called") } } func TestNormalizeMissionClockValueAcceptsDatetime(t *testing.T) { got := util.NormalizeClockValue("2000-01-01 08:00:00") if got != "08:00" { t.Fatalf("expected 08:00, got %q", got) } } func TestMissionServiceDeleteByIDRequiresMissionID(t *testing.T) { svc := NewMissionService(&missionRepoMock{}) err := svc.DeleteByID(context.Background(), nil, nil) if !errors.Is(err, mission.ErrMissionIDRequired) { t.Fatalf("expected ErrMissionIDRequired, got %v", err) } } func TestMissionServiceDeleteByIDDelegatesToRepo(t *testing.T) { wantMissionID := []byte("1234567890123456") wantDeletedBy := []byte("6543210987654321") called := false svc := NewMissionService(&missionRepoMock{ deleteByIDFn: func(_ context.Context, missionID []byte, deletedBy []byte) error { called = true if string(missionID) != string(wantMissionID) { t.Fatalf("unexpected missionID: %x", missionID) } if string(deletedBy) != string(wantDeletedBy) { t.Fatalf("unexpected deletedBy: %x", deletedBy) } return nil }, }) if err := svc.DeleteByID(context.Background(), wantMissionID, wantDeletedBy); err != nil { t.Fatalf("DeleteByID returned error: %v", err) } if !called { t.Fatal("expected repository DeleteByID to be called") } }