package service import ( "context" "testing" "time" "wucher/internal/domain/flight" fmreport "wucher/internal/domain/fm_report" "wucher/internal/shared/pkg/appctx" "wucher/internal/shared/pkg/uuidv7" ) type fmReportRepoMock struct { upsertFn func(context.Context, *fmreport.Report) error getByFlightFn func(context.Context, []byte) (*fmreport.Report, error) maxReportSeqFn func(context.Context, []byte) (int, error) setReportCodeFn func(context.Context, []byte, string) error listFn func(context.Context, fmreport.ListFilter, int, int) ([]fmreport.Report, int64, error) createFleetHistoryFn func(context.Context, *fmreport.FleetHistory) error getFleetHistoryFn func(context.Context, []byte) (*fmreport.FleetHistory, error) } type fmReportUsageRefresherStub struct { calls int } func (s *fmReportUsageRefresherStub) RefreshAll(context.Context) error { s.calls++ return nil } func (m *fmReportRepoMock) MaxReportSeqByHelicopter(ctx context.Context, helicopterID []byte) (int, error) { if m.maxReportSeqFn != nil { return m.maxReportSeqFn(ctx, helicopterID) } return 0, nil } func (m *fmReportRepoMock) SetReportCode(ctx context.Context, id []byte, code string) error { if m.setReportCodeFn != nil { return m.setReportCodeFn(ctx, id, code) } return nil } func fmStrPtr(v string) *string { return &v } func (m *fmReportRepoMock) CreateFleetHistory(ctx context.Context, row *fmreport.FleetHistory) error { if m.createFleetHistoryFn != nil { return m.createFleetHistoryFn(ctx, row) } return nil } func (m *fmReportRepoMock) GetFleetHistoryByReportID(ctx context.Context, reportID []byte) (*fmreport.FleetHistory, error) { if m.getFleetHistoryFn != nil { return m.getFleetHistoryFn(ctx, reportID) } return nil, nil } func (m *fmReportRepoMock) Upsert(ctx context.Context, row *fmreport.Report) error { if m.upsertFn != nil { return m.upsertFn(ctx, row) } return nil } func (m *fmReportRepoMock) GetByFlightID(ctx context.Context, flightID []byte) (*fmreport.Report, error) { if m.getByFlightFn != nil { return m.getByFlightFn(ctx, flightID) } return nil, nil } func (m *fmReportRepoMock) List(ctx context.Context, filter fmreport.ListFilter, limit, offset int) ([]fmreport.Report, int64, error) { if m.listFn != nil { return m.listFn(ctx, filter, limit, offset) } return nil, 0, nil } func TestFMReportServiceUpsertForFlightPreservesExistingMetrics(t *testing.T) { flightID := uuidv7.MustBytes() takeoverID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() existingID := uuidv7.MustBytes() userID := uuidv7.MustBytes() refresher := &fmReportUsageRefresherStub{} repo := &fmReportRepoMock{ getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) { return &fmreport.Report{ ID: existingID, FlightID: flightID, TakeoverID: takeoverID, FlightInspectionID: inspectionID, Engine1GpcN1: fmStrPtr("old-e1-gpc"), Engine1PtcN2: fmStrPtr("old-e1-ptc"), Engine2GpcN1: fmStrPtr("old-e2-gpc"), Engine2PtcN2: fmStrPtr("old-e2-ptc"), CreatedAt: time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC), CreatedBy: userID, }, nil }, upsertFn: func(_ context.Context, row *fmreport.Report) error { if row.Engine1GpcN1 == nil || row.Engine1PtcN2 == nil || row.Engine2GpcN1 == nil || row.Engine2PtcN2 == nil { t.Fatalf("expected metrics preserved, got %+v", row) } if *row.Engine1GpcN1 != "old-e1-gpc" || *row.Engine1PtcN2 != "old-e1-ptc" || *row.Engine2GpcN1 != "old-e2-gpc" || *row.Engine2PtcN2 != "old-e2-ptc" { t.Fatalf("expected metrics preserved, got %+v", row) } return nil }, } svc := NewFMReportService(repo).WithUsageRefresher(refresher) ctx := appctx.WithUserID(context.Background(), userID) got, err := svc.UpsertForFlight(ctx, &flight.Flight{ID: flightID, TakeoverAcID: takeoverID}, inspectionID) if err != nil { t.Fatalf("UpsertForFlight: %v", err) } if got == nil || got.Engine1GpcN1 == nil || got.Engine1PtcN2 == nil || got.Engine2GpcN1 == nil || got.Engine2PtcN2 == nil { t.Fatalf("unexpected report: %+v", got) } if *got.Engine1GpcN1 != "old-e1-gpc" || *got.Engine1PtcN2 != "old-e1-ptc" || *got.Engine2GpcN1 != "old-e2-gpc" || *got.Engine2PtcN2 != "old-e2-ptc" { t.Fatalf("unexpected report: %+v", got) } if refresher.calls != 0 { t.Fatalf("expected no usage refresh on upsert, got %d", refresher.calls) } } func TestFMReportServiceUpdateForFlight(t *testing.T) { flightID := uuidv7.MustBytes() reportID := uuidv7.MustBytes() userID := uuidv7.MustBytes() refresher := &fmReportUsageRefresherStub{} repo := &fmReportRepoMock{ getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) { return &fmreport.Report{ ID: reportID, FlightID: flightID, TakeoverID: uuidv7.MustBytes(), FlightInspectionID: uuidv7.MustBytes(), Engine1GpcN1: fmStrPtr("old-e1-gpc"), Engine1PtcN2: fmStrPtr("old-e1-ptc"), Engine2GpcN1: fmStrPtr("old-e2-gpc"), Engine2PtcN2: fmStrPtr("old-e2-ptc"), }, nil }, upsertFn: func(ctx context.Context, row *fmreport.Report) error { if row.Engine1GpcN1 == nil || row.Engine1PtcN2 == nil || row.Engine2GpcN1 == nil || row.Engine2PtcN2 == nil { t.Fatalf("unexpected patched values: %+v", row) } if *row.Engine1GpcN1 != "new-e1-gpc" || *row.Engine1PtcN2 != "new-e1-ptc" || *row.Engine2GpcN1 != "new-e2-gpc" || *row.Engine2PtcN2 != "new-e2-ptc" { t.Fatalf("unexpected patched values: %+v", row) } if got := appctx.GetUserID(ctx); string(got) != string(userID) { t.Fatalf("expected updated by user to be preserved in context") } return nil }, } svc := NewFMReportService(repo).WithUsageRefresher(refresher) ctx := appctx.WithUserID(context.Background(), userID) got, err := svc.UpdateForFlight(ctx, flightID, fmStrPtr("new-e1-gpc"), fmStrPtr("new-e1-ptc"), fmStrPtr("new-e2-gpc"), fmStrPtr("new-e2-ptc")) if err != nil { t.Fatalf("UpdateForFlight: %v", err) } if got == nil || got.Engine1GpcN1 == nil || got.Engine1PtcN2 == nil || got.Engine2GpcN1 == nil || got.Engine2PtcN2 == nil { t.Fatalf("unexpected report: %+v", got) } if *got.Engine1GpcN1 != "new-e1-gpc" || *got.Engine1PtcN2 != "new-e1-ptc" || *got.Engine2GpcN1 != "new-e2-gpc" || *got.Engine2PtcN2 != "new-e2-ptc" { t.Fatalf("unexpected report: %+v", got) } if refresher.calls != 0 { t.Fatalf("expected no usage refresh on update, got %d", refresher.calls) } } type fmReportCodeResolverMock struct { helicopterID []byte helicopterType string } func (m fmReportCodeResolverMock) ResolveHelicopterForReportCode(context.Context, []byte) ([]byte, string) { return m.helicopterID, m.helicopterType } func TestFMReportServiceUpsertForFlightAssignsReportCode(t *testing.T) { flightID := uuidv7.MustBytes() takeoverID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() userID := uuidv7.MustBytes() var saved *fmreport.Report var setCode string repo := &fmReportRepoMock{ getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) { return nil, nil }, maxReportSeqFn: func(_ context.Context, gotHeli []byte) (int, error) { if string(gotHeli) != string(helicopterID) { t.Fatalf("expected counter scoped to helicopter, got %x", gotHeli) } return 4, nil }, upsertFn: func(_ context.Context, row *fmreport.Report) error { saved = row if string(row.HelicopterID) != string(helicopterID) { t.Fatalf("expected helicopter id persisted, got %x", row.HelicopterID) } if row.ReportCode != nil { t.Fatalf("expected report code unset on insert, got %q", *row.ReportCode) } if len(row.ID) != 16 { row.ID = uuidv7.MustBytes() } return nil }, setReportCodeFn: func(_ context.Context, _ []byte, code string) error { setCode = code return nil }, } svc := NewFMReportService(repo).WithReportCodeResolver(fmReportCodeResolverMock{ helicopterID: helicopterID, helicopterType: "SN0901", }) ctx := appctx.WithUserID(context.Background(), userID) got, err := svc.UpsertForFlight(ctx, &flight.Flight{ID: flightID, TakeoverAcID: takeoverID}, inspectionID) if err != nil { t.Fatalf("UpsertForFlight: %v", err) } if saved == nil { t.Fatalf("expected report upserted") } if setCode != "SN0901-0005" { t.Fatalf("expected report code SN0901-0005 persisted, got %q", setCode) } if got == nil || got.ReportCode == nil || *got.ReportCode != "SN0901-0005" { t.Fatalf("unexpected returned report: %+v", got) } } func TestFMReportServiceUpsertForFlightRetriesOnDuplicateReportCode(t *testing.T) { flightID := uuidv7.MustBytes() takeoverID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() maxCalls := 0 setCalls := 0 repo := &fmReportRepoMock{ getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) { return nil, nil }, maxReportSeqFn: func(context.Context, []byte) (int, error) { // First read sees 4; after the concurrent winner committed SN0901-0005, // the retry sees 5. maxCalls++ if maxCalls == 1 { return 4, nil } return 5, nil }, setReportCodeFn: func(_ context.Context, _ []byte, code string) error { setCalls++ if code == "SN0901-0005" { return fmreport.ErrDuplicateReportCode } return nil }, upsertFn: func(_ context.Context, row *fmreport.Report) error { if len(row.ID) != 16 { row.ID = uuidv7.MustBytes() } return nil }, } svc := NewFMReportService(repo).WithReportCodeResolver(fmReportCodeResolverMock{ helicopterID: helicopterID, helicopterType: "SN0901", }) got, err := svc.UpsertForFlight(context.Background(), &flight.Flight{ID: flightID, TakeoverAcID: takeoverID}, inspectionID) if err != nil { t.Fatalf("UpsertForFlight: %v", err) } if setCalls != 2 { t.Fatalf("expected one retry after duplicate, got %d SetReportCode calls", setCalls) } if got == nil || got.ReportCode == nil || *got.ReportCode != "SN0901-0006" { t.Fatalf("expected report code SN0901-0006 after retry, got %+v", got) } } func TestFMReportServiceUpdateForFlightNotFound(t *testing.T) { svc := NewFMReportService(&fmReportRepoMock{ getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) { return nil, nil }, }) if _, err := svc.UpdateForFlight(context.Background(), uuidv7.MustBytes(), fmStrPtr("a"), fmStrPtr("b"), fmStrPtr("c"), fmStrPtr("d")); err == nil { t.Fatal("expected error") } } func TestFMReportServiceCompleteSetsCompletionFieldsAndRefreshesUsage(t *testing.T) { flightID := uuidv7.MustBytes() reportID := uuidv7.MustBytes() userID := uuidv7.MustBytes() refresher := &fmReportUsageRefresherStub{} repo := &fmReportRepoMock{ getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) { return &fmreport.Report{ ID: reportID, FlightID: flightID, TakeoverID: uuidv7.MustBytes(), FlightInspectionID: uuidv7.MustBytes(), }, nil }, upsertFn: func(ctx context.Context, row *fmreport.Report) error { if row.CompletedAt == nil { t.Fatal("expected completed_at to be set") } if len(row.CompletedBy) != 16 { t.Fatal("expected completed_by to be set") } if got := appctx.GetUserID(ctx); string(got) != string(userID) { t.Fatal("expected user id propagated in context") } return nil }, } svc := NewFMReportService(repo).WithUsageRefresher(refresher) ctx := appctx.WithUserID(context.Background(), userID) got, err := svc.Complete(ctx, flightID) if err != nil { t.Fatalf("Complete: %v", err) } if got == nil || got.CompletedAt == nil || len(got.CompletedBy) != 16 { t.Fatalf("unexpected completed report: %+v", got) } if refresher.calls != 1 { t.Fatalf("expected one usage refresh, got %d", refresher.calls) } }