package service import ( "context" "errors" "testing" "time" afterflightinspection "wucher/internal/domain/after_flight_inspection" flightdomain "wucher/internal/domain/flight" flightdata "wucher/internal/domain/flight_data" fmreport "wucher/internal/domain/fm_report" missiondomain "wucher/internal/domain/mission" reserveac "wucher/internal/domain/reserve_ac" takeoverdomain "wucher/internal/domain/takeover" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/uuidv7" ) type flightDataRepoStub struct { createFn func(context.Context, *flightdata.FlightData) error updateFn func(context.Context, *flightdata.FlightData) error getByIDFn func(context.Context, []byte) (*flightdata.FlightData, error) getByMissionIDFn func(context.Context, []byte) (*flightdata.FlightData, error) getSPODetailsByFlightID func(context.Context, []byte) (*flightdata.SPODetails, error) } type missionResolverStub struct { flightID []byte err error } func (m missionResolverStub) FlightIDByMissionID(context.Context, []byte) ([]byte, error) { return append([]byte(nil), m.flightID...), m.err } type fmReportLookupStub struct { report *fmreport.Report err error } type flightLookupStub struct { flight *flightdomain.Flight err error } type afterFlightInspectionLookupStub struct { row *afterflightinspection.AfterFlightInspection err error } func (s fmReportLookupStub) GetByFlightID(context.Context, []byte) (*fmreport.Report, error) { return s.report, s.err } func (s flightLookupStub) GetByID(context.Context, []byte) (*flightdomain.Flight, error) { return s.flight, s.err } func (s afterFlightInspectionLookupStub) GetByFlightInspectionID(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error) { return s.row, s.err } func (r *flightDataRepoStub) Create(ctx context.Context, row *flightdata.FlightData) error { if r.createFn != nil { return r.createFn(ctx, row) } return nil } func (r *flightDataRepoStub) Update(ctx context.Context, row *flightdata.FlightData) error { if r.updateFn != nil { return r.updateFn(ctx, row) } return nil } func (r *flightDataRepoStub) Delete(context.Context, []byte, []byte) error { return nil } func (r *flightDataRepoStub) GetByID(ctx context.Context, id []byte) (*flightdata.FlightData, error) { if r.getByIDFn != nil { return r.getByIDFn(ctx, id) } return nil, nil } func (r *flightDataRepoStub) GetByFlightID(context.Context, []byte) (*flightdata.FlightData, error) { return nil, nil } func (r *flightDataRepoStub) GetByMissionID(ctx context.Context, id []byte) (*flightdata.FlightData, error) { if r.getByMissionIDFn != nil { return r.getByMissionIDFn(ctx, id) } return nil, nil } func (r *flightDataRepoStub) ListByMissionID(context.Context, []byte) ([]flightdata.FlightData, error) { return nil, nil } func (r *flightDataRepoStub) List(context.Context, string, []byte, []byte, int, int) ([]flightdata.FlightData, int64, error) { return nil, 0, nil } func (r *flightDataRepoStub) GetSPODetailsByFlightDataID(ctx context.Context, id []byte) (*flightdata.SPODetails, error) { if r.getSPODetailsByFlightID != nil { return r.getSPODetailsByFlightID(ctx, id) } return nil, nil } func (r *flightDataRepoStub) UpsertSPODetails(context.Context, []byte, *flightdata.SPOUpsertData) error { return nil } func TestFlightDataServiceCreateSyncsCompleteStatus(t *testing.T) { var updates []*flightdata.FlightData rowID := []byte("019ecc1111111111") repo := &flightDataRepoStub{ getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) { loaded := &flightdata.FlightData{ ID: id, MissionID: []byte("1234567890123456"), CoPilotID: []byte("2345678901234567"), FromICAOID: []byte("3456789012345678"), ToHospitalID: []byte("4567890123456789"), Mission: &missiondomain.Mission{Type: sharedconst.MissionTypeCAT}, FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, Status: flightdata.StatusInProgress, } if got := flightdata.DeriveStatus(loaded, nil); got != flightdata.StatusCompleted { t.Fatalf("expected derived status complete, got %q", got) } return loaded, nil }, updateFn: func(_ context.Context, row *flightdata.FlightData) error { updates = append(updates, row) return nil }, } svc := NewFlightDataService(repo) if err := svc.syncStatus(context.Background(), rowID); err != nil { t.Fatalf("syncStatus returned error: %v", err) } if len(updates) != 1 { t.Fatalf("expected one status sync update, got %d", len(updates)) } if updates[0].Status != flightdata.StatusCompleted { t.Fatalf("expected sync update to complete status, got %q", updates[0].Status) } } func TestFlightDataServiceCreateKeepsSPOIncompleteStatus(t *testing.T) { rowID := []byte("019ecc1122222222") repo := &flightDataRepoStub{ getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) { loaded := &flightdata.FlightData{ ID: id, MissionID: []byte("1234567890123456"), CoPilotID: []byte("2345678901234567"), FromICAOID: []byte("3456789012345678"), ToHospitalID: []byte("4567890123456789"), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, Mission: &missiondomain.Mission{Type: sharedconst.MissionTypeSPO}, Status: flightdata.StatusInProgress, } if got := flightdata.DeriveStatus(loaded, nil); got != flightdata.StatusInProgress { t.Fatalf("expected derived status on_progress, got %q", got) } return loaded, nil }, getSPODetailsByFlightID: func(context.Context, []byte) (*flightdata.SPODetails, error) { return &flightdata.SPODetails{}, nil }, } svc := NewFlightDataService(repo) if err := svc.syncStatus(context.Background(), rowID); err != nil { t.Fatalf("syncStatus returned error: %v", err) } } func TestFlightDataServiceCreateSyncsCompleteStatusWithoutRotorBrakeCycleForCATAndNCO(t *testing.T) { for _, tc := range []struct { name string typ string }{ {name: "cat", typ: sharedconst.MissionTypeCAT}, {name: "nco", typ: sharedconst.MissionTypeNCO}, } { t.Run(tc.name, func(t *testing.T) { var updates []*flightdata.FlightData rowID := []byte("019ecc1144444444") repo := &flightDataRepoStub{ getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) { loaded := &flightdata.FlightData{ ID: id, MissionID: []byte("1234567890123456"), CoPilotID: []byte("2345678901234567"), FromICAOID: []byte("3456789012345678"), ToHospitalID: []byte("4567890123456789"), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, Mission: &missiondomain.Mission{Type: tc.typ}, Status: flightdata.StatusInProgress, } if got := flightdata.DeriveStatus(loaded, nil); got != flightdata.StatusCompleted { t.Fatalf("expected derived status complete, got %q", got) } return loaded, nil }, updateFn: func(_ context.Context, row *flightdata.FlightData) error { updates = append(updates, row) return nil }, } svc := NewFlightDataService(repo) if err := svc.syncStatus(context.Background(), rowID); err != nil { t.Fatalf("syncStatus returned error: %v", err) } if len(updates) != 1 { t.Fatalf("expected one status sync update, got %d", len(updates)) } if updates[0].Status != flightdata.StatusCompleted { t.Fatalf("expected sync update to complete status, got %q", updates[0].Status) } }) } } func TestFlightDataServiceSyncStatusFallsBackToMissionLookup(t *testing.T) { var updates []*flightdata.FlightData rowID := []byte("019ecc1133333333") missionID := []byte("1234567890123456") repo := &flightDataRepoStub{ getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) { return &flightdata.FlightData{ ID: id, MissionID: missionID, CoPilotID: []byte("2345678901234567"), FromHospitalID: []byte("3456789012345678"), ToHospitalID: []byte("4567890123456789"), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, Status: flightdata.StatusInProgress, }, nil }, getByMissionIDFn: func(_ context.Context, _ []byte) (*flightdata.FlightData, error) { return &flightdata.FlightData{ ID: rowID, MissionID: missionID, CoPilotID: []byte("2345678901234567"), FromHospitalID: []byte("3456789012345678"), ToHospitalID: []byte("4567890123456789"), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, Mission: &missiondomain.Mission{Type: sharedconst.MissionTypeCAT}, Status: flightdata.StatusInProgress, }, nil }, updateFn: func(_ context.Context, row *flightdata.FlightData) error { updates = append(updates, row) return nil }, } svc := NewFlightDataService(repo) if err := svc.syncStatus(context.Background(), rowID); err != nil { t.Fatalf("syncStatus returned error: %v", err) } if len(updates) != 1 { t.Fatalf("expected one status sync update, got %d", len(updates)) } if updates[0].Status != flightdata.StatusCompleted { t.Fatalf("expected sync update to complete status, got %q", updates[0].Status) } } func TestFlightDataServiceCreateBlockedWhenFMReportExists(t *testing.T) { missionID := uuidv7.MustBytes() repo := &flightDataRepoStub{ createFn: func(context.Context, *flightdata.FlightData) error { t.Fatal("create should not be called when fm report already exists") return nil }, } svc := NewFlightDataService(repo). WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}). WithFMReportLookup(fmReportLookupStub{report: &fmreport.Report{ID: uuidv7.MustBytes(), FlightID: uuidv7.MustBytes()}}) err := svc.Create(context.Background(), &flightdata.FlightData{ MissionID: missionID, CoPilotID: uuidv7.MustBytes(), FromHospitalID: uuidv7.MustBytes(), ToICAOID: uuidv7.MustBytes(), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, }) if !errors.Is(err, flightdata.ErrCreateBlocked) { t.Fatalf("expected create blocked, got %v", err) } } func TestFlightDataServiceUpdateLockedWhenFMReportCompleted(t *testing.T) { missionID := uuidv7.MustBytes() repo := &flightDataRepoStub{ getByIDFn: func(context.Context, []byte) (*flightdata.FlightData, error) { return &flightdata.FlightData{ ID: uuidv7.MustBytes(), MissionID: missionID, CoPilotID: uuidv7.MustBytes(), FromHospitalID: uuidv7.MustBytes(), ToICAOID: uuidv7.MustBytes(), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, }, nil }, updateFn: func(context.Context, *flightdata.FlightData) error { t.Fatal("update should not be called when fm report is completed") return nil }, } completedAt := time.Now().UTC() svc := NewFlightDataService(repo). WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}). WithFMReportLookup(fmReportLookupStub{report: &fmreport.Report{ID: uuidv7.MustBytes(), FlightID: uuidv7.MustBytes(), CompletedAt: &completedAt}}) err := svc.Update(context.Background(), &flightdata.FlightData{ ID: uuidv7.MustBytes(), MissionID: missionID, CoPilotID: uuidv7.MustBytes(), FromHospitalID: uuidv7.MustBytes(), ToICAOID: uuidv7.MustBytes(), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, }) if !errors.Is(err, flightdata.ErrLocked) { t.Fatalf("expected locked error, got %v", err) } } func TestFlightDataServiceCreateBlockedWhenAfterFlightInspectionExists(t *testing.T) { missionID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() repo := &flightDataRepoStub{ createFn: func(context.Context, *flightdata.FlightData) error { t.Fatal("create should not be called when after flight inspection exists") return nil }, } svc := NewFlightDataService(repo). WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}). WithFlightLookup(flightLookupStub{ flight: &flightdomain.Flight{ ID: uuidv7.MustBytes(), Takeover: &takeoverdomain.TakeoverAc{ ReserveAc: &reserveac.ReserveAc{InspectionID: inspectionID}, }, }, }). WithAfterFlightInspectionLookup(afterFlightInspectionLookupStub{row: &afterflightinspection.AfterFlightInspection{ID: uuidv7.MustBytes(), FlightInspectionID: inspectionID}}) err := svc.Create(context.Background(), &flightdata.FlightData{ MissionID: missionID, CoPilotID: uuidv7.MustBytes(), FromHospitalID: uuidv7.MustBytes(), ToICAOID: uuidv7.MustBytes(), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, }) if !errors.Is(err, flightdata.ErrLockedAfterFlightInspection) { t.Fatalf("expected after-flight inspection lock error, got %v", err) } } func TestFlightDataServiceUpdateAllowedWhenAfterFlightInspectionExists(t *testing.T) { missionID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() updated := false repo := &flightDataRepoStub{ getByIDFn: func(context.Context, []byte) (*flightdata.FlightData, error) { return &flightdata.FlightData{ ID: uuidv7.MustBytes(), MissionID: missionID, CoPilotID: uuidv7.MustBytes(), FromHospitalID: uuidv7.MustBytes(), ToICAOID: uuidv7.MustBytes(), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, }, nil }, updateFn: func(context.Context, *flightdata.FlightData) error { updated = true return nil }, } svc := NewFlightDataService(repo). WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}). WithFlightLookup(flightLookupStub{ flight: &flightdomain.Flight{ ID: uuidv7.MustBytes(), Takeover: &takeoverdomain.TakeoverAc{ ReserveAc: &reserveac.ReserveAc{InspectionID: inspectionID}, }, }, }). WithAfterFlightInspectionLookup(afterFlightInspectionLookupStub{row: &afterflightinspection.AfterFlightInspection{ID: uuidv7.MustBytes(), FlightInspectionID: inspectionID}}) err := svc.Update(context.Background(), &flightdata.FlightData{ ID: uuidv7.MustBytes(), MissionID: missionID, CoPilotID: uuidv7.MustBytes(), FromHospitalID: uuidv7.MustBytes(), ToICAOID: uuidv7.MustBytes(), FlightTakeOff: time.Now().UTC(), FlightLanding: time.Now().UTC(), FlightDuration: time.Hour, LandingCount: 1, RotorBrakeCycle: 1, }) if err != nil { t.Fatalf("expected update to be allowed, got %v", err) } if !updated { t.Fatal("expected update to be called") } } func TestFlightDataServiceUpdateRequiresRow(t *testing.T) { svc := NewFlightDataService(&flightDataRepoStub{}) if err := svc.Update(context.Background(), nil); !errors.Is(err, flightdata.ErrRequired) { t.Fatalf("expected ErrRequired, got %v", err) } }