package service import ( "context" "testing" "time" fleethistory "wucher/internal/domain/fleet_history" fleetstatus "wucher/internal/domain/fleet_status" "wucher/internal/domain/helicopter" helicopterusage "wucher/internal/domain/helicopter_usage" ) type stubFleetRepo struct { latest map[string]*fleetstatus.FleetStatus missionHeli []byte created *fleetstatus.FleetStatus updated *fleetstatus.FleetStatus deletedID []byte servicedID []byte } func (s *stubFleetRepo) Create(_ context.Context, row *fleetstatus.FleetStatus) error { s.created = row return nil } func (s *stubFleetRepo) Update(_ context.Context, row *fleetstatus.FleetStatus) error { s.updated = row return nil } func (s *stubFleetRepo) Delete(_ context.Context, id, _ []byte) error { s.deletedID = id; return nil } func (s *stubFleetRepo) GetByID(_ context.Context, _ []byte) (*fleetstatus.FleetStatus, error) { return s.created, nil } func (s *stubFleetRepo) List(_ context.Context, _, _ string, _, _ int) ([]fleetstatus.FleetStatus, int64, error) { return nil, 0, nil } func (s *stubFleetRepo) LatestByHelicopterIDs(_ context.Context, _ [][]byte) (map[string]*fleetstatus.FleetStatus, error) { return s.latest, nil } func (s *stubFleetRepo) MarkServiced(_ context.Context, id []byte, _ time.Time, _ []byte) error { s.servicedID = id return nil } func (s *stubFleetRepo) ListServiceHistoryByHelicopterID(_ context.Context, _ []byte, _, _ int) ([]fleetstatus.FleetStatusServiceLog, int64, error) { return nil, 0, nil } func (s *stubFleetRepo) HelicopterIDByMissionID(_ context.Context, _ []byte) ([]byte, error) { return s.missionHeli, nil } func (s *stubFleetRepo) HelicopterIDsWithFleetStatus(_ context.Context) ([][]byte, error) { out := make([][]byte, 0, len(s.latest)) for k := range s.latest { out = append(out, []byte(k)) } return out, nil } type stubFleetHelis struct { heli *helicopter.Helicopter updated *helicopter.Helicopter } func (s *stubFleetHelis) GetByID(_ context.Context, _ []byte) (*helicopter.Helicopter, error) { return s.heli, nil } func (s *stubFleetHelis) Update(_ context.Context, h *helicopter.Helicopter) error { s.updated = h return nil } type stubFleetUsage struct { usage *helicopterusage.HelicopterUsage } func (s *stubFleetUsage) GetByHelicopterID(_ context.Context, _ []byte) (*helicopterusage.HelicopterUsage, error) { return s.usage, nil } func (s *stubFleetUsage) ReviseManualTotals(_ context.Context, _ []byte, _ helicopterusage.ManualSummaryInput, _ []byte) error { return nil } func fleetUsage(airframeHours float64) *stubFleetUsage { return &stubFleetUsage{usage: &helicopterusage.HelicopterUsage{TotalAirframeHours: airframeHours}} } type stubHistoryRepo struct{ recorded int } func (s *stubHistoryRepo) Create(_ context.Context, _ *fleethistory.FleetHistory) error { s.recorded++ return nil } func (s *stubHistoryRepo) ListByHelicopter(_ context.Context, _ []byte, _, _ int) ([]fleethistory.FleetHistory, int64, error) { return nil, 0, nil } func (s *stubHistoryRepo) HelicopterIDByInspection(_ context.Context, _ []byte) ([]byte, error) { return nil, nil } func newHistory() *FleetHistoryService { return NewFleetHistoryService(&stubHistoryRepo{}) } func heliID() []byte { return []byte("0123456789abcdef") } func fleetWith(schedDue string) *fleetstatus.FleetStatus { return &fleetstatus.FleetStatus{ ID: []byte("fedcba9876543210"), HelicopterID: heliID(), MaintenanceSchedules: []fleetstatus.MaintenanceSchedule{ {InspectionType: fleetstatus.InspectionTypeAF, Due: schedDue}, }, } } func TestSyncAutomaticAOG_SetWhenOverdue(t *testing.T) { row := fleetWith("100") repo := &stubFleetRepo{ latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row}, } helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}} svc := NewFleetStatusService(repo, nil, helis, nil).WithHistory(newHistory()).WithUsage(fleetUsage(150)) if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil { t.Fatalf("err: %v", err) } if helis.updated == nil || !helis.updated.AirOnGround { t.Fatal("expected helicopter set AOG (150h > 100h due)") } if helis.updated.AOGSource != helicopter.AOGSourceAuto { t.Fatalf("expected auto AOG source, got %q", helis.updated.AOGSource) } } func TestSyncAutomaticAOG_NotOverdueNotGrounded(t *testing.T) { row := fleetWith("100") repo := &stubFleetRepo{ latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row}, } helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}} svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(fleetUsage(50)) if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil { t.Fatalf("err: %v", err) } if helis.updated != nil { t.Fatal("not overdue and not previously auto-AOG: should not touch helicopter") } } func TestSyncAutomaticAOG_NoUsageDataNotGrounded(t *testing.T) { row := fleetWith("100") repo := &stubFleetRepo{ latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row}, } helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}} svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(&stubFleetUsage{usage: nil}) if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil { t.Fatalf("err: %v", err) } if helis.updated != nil { t.Fatal("fresh aircraft with no usage data must not be auto-AOG on an hours due") } } func TestSyncAutomaticAOG_ClearWhenNoLongerOverdue(t *testing.T) { row := fleetWith("100") repo := &stubFleetRepo{ latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row}, } helis := &stubFleetHelis{heli: &helicopter.Helicopter{ ID: heliID(), NR1: true, NR2: true, AirOnGround: true, AOGSource: helicopter.AOGSourceAuto, AOGReason: "[auto_next_due] A/F due hours reached", }} svc := NewFleetStatusService(repo, nil, helis, nil).WithHistory(newHistory()).WithUsage(fleetUsage(50)) if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil { t.Fatalf("err: %v", err) } if helis.updated == nil || helis.updated.AirOnGround { t.Fatal("auto-AOG should be cleared once no longer overdue") } } func TestFleetStatusCreateUpdateGet(t *testing.T) { repo := &stubFleetRepo{} svc := NewFleetStatusService(repo, nil, nil, nil) // attachments nil -> simple path ctx := context.Background() row := &fleetstatus.FleetStatus{HelicopterID: heliID()} if err := svc.Create(ctx, row); err != nil || repo.created != row { t.Fatalf("Create: %v", err) } if err := svc.Update(ctx, row); err != nil || repo.updated != row { t.Fatalf("Update: %v", err) } if _, err := svc.GetByID(ctx, heliID()); err != nil { t.Fatalf("GetByID: %v", err) } } func TestRecalculateAOGByMission(t *testing.T) { row := fleetWith("100") repo := &stubFleetRepo{ latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row}, missionHeli: heliID(), } helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}} svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(fleetUsage(150)) if err := svc.RecalculateAOGByMission(context.Background(), []byte("mission0123456789")); err != nil { t.Fatalf("err: %v", err) } if helis.updated == nil || !helis.updated.AirOnGround { t.Fatal("mission recalc should set AOG") } // empty mission id -> no-op if err := svc.RecalculateAOGByMission(context.Background(), nil); err != nil { t.Fatalf("nil mission err: %v", err) } // no fleet status for helicopter -> no-op if err := NewFleetStatusService(&stubFleetRepo{}, nil, helis, nil).RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil { t.Fatalf("no-fleet-status err: %v", err) } } func TestRecalculateAllAOG(t *testing.T) { row := fleetWith("100") repo := &stubFleetRepo{ latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row}, } helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}} svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(fleetUsage(150)) n, err := svc.RecalculateAllAOG(context.Background()) if err != nil { t.Fatalf("err: %v", err) } if n != 1 { t.Fatalf("expected 1 helicopter recomputed, got %d", n) } if helis.updated == nil || !helis.updated.AirOnGround { t.Fatal("overdue helicopter should have been set AOG by the sweep") } } func TestGetSummaryByHelicopterID_EngineGating(t *testing.T) { usage := &stubFleetUsage{usage: &helicopterusage.HelicopterUsage{ TotalAirframeHours: 6620, TotalEngine1Hours: 8011, TotalEngine2Hours: 5981, }} helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: false}} svc := NewFleetStatusService(&stubFleetRepo{}, nil, helis, nil).WithUsage(usage) summary, err := svc.GetSummaryByHelicopterID(context.Background(), heliID()) if err != nil { t.Fatalf("err: %v", err) } assertFloatPtr(t, "airframe", summary.Airframe.Hours, ptr(6620.0)) assertFloatPtr(t, "engine1", summary.Engine1.Hours, ptr(8011.0)) if summary.Engine2.Hours != nil { t.Fatal("engine 2 absent (NR2 false) -> hours must be nil") } } func TestFleetStatusSimpleDelegators(t *testing.T) { repo := &stubFleetRepo{} svc := NewFleetStatusService(repo, nil, nil, nil).WithHistory(nil) ctx := context.Background() if err := svc.Delete(ctx, []byte("id"), nil); err != nil || string(repo.deletedID) != "id" { t.Fatalf("Delete: %v", err) } if _, _, err := svc.List(ctx, "", "", 0, 0); err != nil { t.Fatalf("List: %v", err) } if _, err := svc.LatestByHelicopterIDs(ctx, [][]byte{heliID()}); err != nil { t.Fatalf("LatestByHelicopterIDs: %v", err) } if _, _, err := svc.ListServiceHistoryByHelicopterID(ctx, heliID(), 0, 0); err != nil { t.Fatalf("ListServiceHistory: %v", err) } if err := svc.MarkServiced(ctx, []byte("ms"), time.Time{}, nil); err != nil || string(repo.servicedID) != "ms" { t.Fatalf("MarkServiced: %v", err) } }