package handlers import ( "context" "fmt" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "gorm.io/driver/sqlite" "gorm.io/gorm" "wucher/internal/domain/base" filemanager "wucher/internal/domain/file_manager" "wucher/internal/domain/flight" flightinspection "wucher/internal/domain/flight_inspection" helicopterdomain "wucher/internal/domain/helicopter" helicopterfile "wucher/internal/domain/helicopter_file" reserveac "wucher/internal/domain/reserve_ac" takeoverdomain "wucher/internal/domain/takeover" mysqlrepo "wucher/internal/repository/mysql" "wucher/internal/service" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" responsedto "wucher/internal/transport/http/dto/response" shareddto "wucher/internal/transport/http/dto/shared" ) type takeoverTestUserNameGetter struct { names map[string]string } func (g takeoverTestUserNameGetter) GetLastName(_ context.Context, userID []byte) (string, error) { return "", nil } func (g takeoverTestUserNameGetter) GetShortName(_ context.Context, userID []byte) (string, error) { if len(userID) == 0 { return "", nil } if name, ok := g.names[idString(userID)]; ok { return name, nil } return "", nil } func TestTakeoverHandler_buildTakeoverResponseAttributes_MapsReserveAcRelations(t *testing.T) { h := &TakeoverHandler{} takeoverID := uuidv7.MustBytes() baseID := uuidv7.MustBytes() flightID := uuidv7.MustBytes() reserveID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() notes := " take-over notes " inspectionSummary := &shareddto.ReserveAcInspectionSummary{ID: idString(inspectionID), InspectionDate: "2026-04-20"} dutyResource := responsedto.DutyRosterResource{ Attributes: responsedto.DutyRosterAttributes{ ShiftTime: responsedto.DutyRosterShiftTime{ShiftStart: "06:00", ShiftEnd: "14:00"}, DutyDate: "2026-04-20", Detail: responsedto.DutyRosterDetailsResponse{ Pilot: []responsedto.DutyRosterPilotCrew{{ID: idString(uuidv7.MustBytes()), Role: "pilot"}}, }, }, } takeoverRow := &takeoverdomain.TakeoverAc{ID: takeoverID, BaseID: baseID, Notes: ¬es} createdBy := uuidv7.MustBytes() updatedBy := uuidv7.MustBytes() takeoverRow.CreatedBy = createdBy takeoverRow.UpdatedBy = updatedBy takeoverRow.CreatedAt = time.Date(2026, 3, 12, 3, 4, 5, 0, time.UTC) takeoverRow.UpdatedAt = time.Date(2026, 7, 6, 8, 12, 18, 0, time.UTC) flightRow := &flight.Flight{ID: flightID} reserveRow := &reserveac.ReserveAc{ ID: reserveID, InspectionID: inspectionID, AircraftID: helicopterID, Aircraft: &helicopterdomain.Helicopter{ID: helicopterID, Designation: "H145", Identifier: "D-HAAA", Type: "H145"}, } attrs := h.buildTakeoverResponseAttributes(nil, takeoverRow, flightRow, reserveRow, inspectionSummary, dutyResource) if got, want := attrs.ID, idString(takeoverID); got != want { t.Fatalf("unexpected takeover id: got %s want %s", got, want) } if got, want := attrs.FlightID, idString(flightID); got != want { t.Fatalf("unexpected flight id: got %s want %s", got, want) } if got, want := attrs.ReserveAcID, idString(reserveID); got != want { t.Fatalf("unexpected reserve_ac_id: got %s want %s", got, want) } if got, want := attrs.FlightInspectionID, idString(inspectionID); got != want { t.Fatalf("unexpected flight_inspection_id: got %s want %s", got, want) } if got, want := attrs.HelicopterID, idString(helicopterID); got != want { t.Fatalf("unexpected helicopter_id: got %s want %s", got, want) } if attrs.Notes == nil || *attrs.Notes != notes { t.Fatalf("unexpected notes: %#v", attrs.Notes) } if attrs.Inspection.ID != idString(inspectionID) { t.Fatalf("unexpected inspection summary: %#v", attrs.Inspection) } if attrs.Base == nil || attrs.Base.ID != idString(baseID) { t.Fatalf("unexpected base: %#v", attrs.Base) } if attrs.Base.ShiftTime.ShiftStart != "06:00" || attrs.Base.ShiftTime.ShiftEnd != "14:00" { t.Fatalf("unexpected base shift time: %#v", attrs.Base.ShiftTime) } if attrs.CreatedBy != uuidStringOrEmpty(createdBy) { t.Fatalf("unexpected created_by: %q", attrs.CreatedBy) } if attrs.UpdatedBy != uuidStringOrEmpty(updatedBy) { t.Fatalf("unexpected updated_by: %q", attrs.UpdatedBy) } if attrs.CreatedAt != takeoverRow.CreatedAt.UTC().Format(time.RFC3339) { t.Fatalf("unexpected created_at: %q", attrs.CreatedAt) } if attrs.UpdatedAt != takeoverRow.UpdatedAt.UTC().Format(time.RFC3339) { t.Fatalf("unexpected updated_at: %q", attrs.UpdatedAt) } } func TestFlightTakeoverStep_UsesUpdatedByShortName(t *testing.T) { prev := userctx.GlobalUserNameGetter updatedBy := uuidv7.MustBytes() createdBy := uuidv7.MustBytes() userctx.WireUserNameGetter(takeoverTestUserNameGetter{ names: map[string]string{ idString(updatedBy): "JON", idString(createdBy): "GAN", }, }) t.Cleanup(func() { userctx.WireUserNameGetter(prev) }) pilotID := uuidv7.MustBytes() takeoverID := uuidv7.MustBytes() baseID := uuidv7.MustBytes() reserveID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() row := &flight.Flight{ ID: uuidv7.MustBytes(), TakeoverAcID: takeoverID, CreatedAt: time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC), } takeoverRow := &takeoverdomain.TakeoverAc{ ID: takeoverID, BaseID: baseID, CreatedBy: createdBy, UpdatedBy: updatedBy, ReserveAc: &reserveac.ReserveAc{ ID: reserveID, AircraftID: helicopterID, InspectionID: inspectionID, Aircraft: &helicopterdomain.Helicopter{ID: helicopterID, Designation: "H145", Identifier: "D-HAAA", Type: "H145"}, Inspection: &flightinspection.FlightInspection{ID: inspectionID}, }, RosterCrews: []takeoverdomain.TakeoverRosterCrew{ { RoleCode: "pilot", UserID: pilotID, NameLabel: "Pilot Full Name", }, }, } step := flightTakeoverStep(context.Background(), row, &takeoverServiceMock{ getByIDFn: func(context.Context, []byte) (*takeoverdomain.TakeoverAc, error) { return takeoverRow, nil }, }) if step.CompletedBy != "JON" { t.Fatalf("expected updated_by short name, got %q", step.CompletedBy) } } func TestTakeoverHandler_buildTakeoverResponseAttributes_MapsFiles(t *testing.T) { h := &TakeoverHandler{} takeoverID := uuidv7.MustBytes() attachmentID := uuidv7.MustBytes() fileID := uuidv7.MustBytes() takeoverRow := &takeoverdomain.TakeoverAc{ ID: takeoverID, Files: []takeoverdomain.TakeoverFile{ { ID: uuidv7.MustBytes(), TakeoverID: takeoverID, FileAttachmentID: attachmentID, FileAttachment: &filemanager.Attachment{ ID: attachmentID, FileID: fileID, File: &filemanager.File{ ID: fileID, Name: "takeover-report.pdf", ObjectKey: "files/2026/06/23/takeover-report.pdf", Bucket: "bucket", }, }, }, }, } attrs := h.buildTakeoverResponseAttributes(nil, takeoverRow, &flight.Flight{ID: uuidv7.MustBytes()}, nil, nil, responsedto.DutyRosterResource{Attributes: responsedto.DutyRosterAttributes{Detail: responsedto.DutyRosterDetailsResponse{}}}) if len(attrs.Files) != 1 { t.Fatalf("expected 1 takeover file, got %d", len(attrs.Files)) } if attrs.Files[0].Attributes.FileAttachmentID != idString(attachmentID) { t.Fatalf("unexpected attachment id: %#v", attrs.Files[0]) } if attrs.Files[0].Attributes.FileName != "takeover-report.pdf" { t.Fatalf("unexpected file name: %#v", attrs.Files[0]) } } func TestTakeoverHandler_buildTakeoverResponseAttributes_HidesTemplateDerivedFiles(t *testing.T) { h := &TakeoverHandler{} takeoverID := uuidv7.MustBytes() regularAttID := uuidv7.MustBytes() regularFileID := uuidv7.MustBytes() templateAttID := uuidv7.MustBytes() templateFileID := uuidv7.MustBytes() takeoverRow := &takeoverdomain.TakeoverAc{ ID: takeoverID, Files: []takeoverdomain.TakeoverFile{ { // regular attached file — must stay visible ID: uuidv7.MustBytes(), TakeoverID: takeoverID, FileAttachmentID: regularAttID, FileAttachment: &filemanager.Attachment{ ID: regularAttID, FileID: regularFileID, File: &filemanager.File{ ID: regularFileID, Name: "evidence.pdf", ObjectKey: "files/evidence.pdf", Bucket: "bucket", }, }, }, { // created from a template — must be hidden (kept in DB) ID: uuidv7.MustBytes(), TakeoverID: takeoverID, FileAttachmentID: templateAttID, FileAttachment: &filemanager.Attachment{ ID: templateAttID, FileID: templateFileID, File: &filemanager.File{ ID: templateFileID, Name: "from-template.pdf", ObjectKey: "files/from-template.pdf", Bucket: "bucket", TemplateUUID: uuidv7.MustBytes(), }, }, }, }, } attrs := h.buildTakeoverResponseAttributes(nil, takeoverRow, &flight.Flight{ID: uuidv7.MustBytes()}, nil, nil, responsedto.DutyRosterResource{Attributes: responsedto.DutyRosterAttributes{Detail: responsedto.DutyRosterDetailsResponse{}}}) if len(attrs.Files) != 1 { t.Fatalf("expected only the regular file to be visible, got %d: %#v", len(attrs.Files), attrs.Files) } if attrs.Files[0].Attributes.FileName != "evidence.pdf" { t.Fatalf("expected evidence.pdf visible, got %#v", attrs.Files[0]) } } func TestTakeoverHandler_ChecklistBuilderHidesTemplateDerivedFiles(t *testing.T) { h := &TakeoverHandler{} visibleID := uuidv7.MustBytes() derivedID := uuidv7.MustBytes() files := []helicopterfile.HelicopterFile{ {ID: visibleID}, { ID: derivedID, SourceFile: &filemanager.File{ TemplateUUID: uuidv7.MustBytes(), }, }, } out := h.buildChecklistSectionSummary(&fiber.Ctx{}, files, map[string]bool{idString(visibleID): true, idString(derivedID): true}) if out.Total != 1 || out.Completed != 1 || len(out.FileChecklist) != 1 || len(out.DoneFiles) != 1 { t.Fatalf("unexpected filtered checklist summary: %#v", out) } if out.FileChecklist[0].HelicopterFileID != idString(visibleID) { t.Fatalf("expected only visible file in checklist, got %#v", out.FileChecklist[0]) } } func TestTakeoverHandler_buildTakeoverResponseAttributes_LoadsFilesFromDBFallback(t *testing.T) { db := openTakeoverHandlerTestDB(t) if err := db.AutoMigrate(&filemanager.Folder{}, &filemanager.File{}, &filemanager.Attachment{}, &takeoverdomain.TakeoverAc{}, &takeoverdomain.TakeoverFile{}); err != nil { t.Fatalf("auto migrate: %v", err) } rootFolderID := uuidv7.MustBytes() if err := db.Create(&filemanager.Folder{ ID: rootFolderID, Name: "takeovers", NameNormalized: "takeovers", Depth: 0, PathCache: "/takeovers", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create folder: %v", err) } takeoverID := uuidv7.MustBytes() fileID := uuidv7.MustBytes() attachmentID := uuidv7.MustBytes() if err := db.Create(&takeoverdomain.TakeoverAc{ID: takeoverID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}).Error; err != nil { t.Fatalf("create takeover: %v", err) } if err := db.Create(&filemanager.File{ ID: fileID, FolderID: rootFolderID, Name: "takeover-report.pdf", NameNormalized: "takeover-report-pdf", SizeBytes: 1, MimeType: "application/pdf", Bucket: "bucket", ObjectKey: "files/2026/06/23/takeover-report.pdf", ChecksumSHA256: strings.Repeat("a", 64), Status: filemanager.FileStatusReady, LifecycleStatus: filemanager.FileLifecycleStatusActive, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create file: %v", err) } if err := db.Create(&filemanager.Attachment{ ID: attachmentID, FileID: fileID, RefType: "takeover", RefID: idString(takeoverID), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create attachment: %v", err) } if err := db.Create(&takeoverdomain.TakeoverFile{ ID: uuidv7.MustBytes(), TakeoverID: takeoverID, FileAttachmentID: attachmentID, }).Error; err != nil { t.Fatalf("create takeover file: %v", err) } h := &TakeoverHandler{db: db} attrs := h.buildTakeoverResponseAttributes(nil, &takeoverdomain.TakeoverAc{ID: takeoverID}, &flight.Flight{ID: uuidv7.MustBytes()}, nil, nil, responsedto.DutyRosterResource{Attributes: responsedto.DutyRosterAttributes{Detail: responsedto.DutyRosterDetailsResponse{}}}) if len(attrs.Files) != 1 { t.Fatalf("expected 1 takeover file from db fallback, got %d", len(attrs.Files)) } if attrs.Files[0].Attributes.FileAttachmentID != idString(attachmentID) { t.Fatalf("unexpected file attachment id: %#v", attrs.Files[0]) } if attrs.Files[0].Attributes.FileName != "takeover-report.pdf" { t.Fatalf("unexpected file name from db fallback: %#v", attrs.Files[0]) } } func TestTakeoverHandler_buildTakeoverResponseAttributes_LoadsFilesFromAttachmentFallback(t *testing.T) { db := openTakeoverHandlerTestDB(t) if err := db.AutoMigrate(&filemanager.Folder{}, &filemanager.File{}, &filemanager.Attachment{}, &takeoverdomain.TakeoverAc{}); err != nil { t.Fatalf("auto migrate: %v", err) } rootFolderID := uuidv7.MustBytes() if err := db.Create(&filemanager.Folder{ ID: rootFolderID, Name: "takeovers", NameNormalized: "takeovers", Depth: 0, PathCache: "/takeovers", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create folder: %v", err) } takeoverID := uuidv7.MustBytes() fileID := uuidv7.MustBytes() attachmentID := uuidv7.MustBytes() if err := db.Create(&takeoverdomain.TakeoverAc{ID: takeoverID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}).Error; err != nil { t.Fatalf("create takeover: %v", err) } if err := db.Create(&filemanager.File{ ID: fileID, FolderID: rootFolderID, Name: "takeover-report.pdf", NameNormalized: "takeover-report-pdf", SizeBytes: 1, MimeType: "application/pdf", Bucket: "bucket", ObjectKey: "files/2026/06/23/takeover-report.pdf", ChecksumSHA256: strings.Repeat("b", 64), Status: filemanager.FileStatusReady, LifecycleStatus: filemanager.FileLifecycleStatusActive, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create file: %v", err) } if err := db.Create(&filemanager.Attachment{ ID: attachmentID, FileID: fileID, RefType: "takeover", RefID: idString(takeoverID), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }).Error; err != nil { t.Fatalf("create attachment: %v", err) } h := &TakeoverHandler{db: db} attrs := h.buildTakeoverResponseAttributes(nil, &takeoverdomain.TakeoverAc{ID: takeoverID}, &flight.Flight{ID: uuidv7.MustBytes()}, nil, nil, responsedto.DutyRosterResource{Attributes: responsedto.DutyRosterAttributes{Detail: responsedto.DutyRosterDetailsResponse{}}}) if len(attrs.Files) != 1 { t.Fatalf("expected 1 takeover file from attachment fallback, got %d", len(attrs.Files)) } if attrs.Files[0].Attributes.FileAttachmentID != idString(attachmentID) { t.Fatalf("unexpected file attachment id: %#v", attrs.Files[0]) } if attrs.Files[0].Attributes.FileName != "takeover-report.pdf" { t.Fatalf("unexpected file name from attachment fallback: %#v", attrs.Files[0]) } } func TestTakeoverEditedTemplateFileBelongsToTakeover(t *testing.T) { takeoverA := uuidv7.MustBytes() takeoverB := uuidv7.MustBytes() tests := []struct { name string row *filemanager.File want bool }{ { name: "allows unattached draft", row: &filemanager.File{}, want: true, }, { name: "allows matching takeover draft", row: &filemanager.File{TakeoverID: append([]byte(nil), takeoverA...)}, want: true, }, { name: "rejects different takeover draft", row: &filemanager.File{TakeoverID: append([]byte(nil), takeoverB...)}, want: false, }, { name: "rejects invalid stored takeover id", row: &filemanager.File{TakeoverID: []byte("bad")}, want: false, }, { name: "rejects nil row", row: nil, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := takeoverEditedTemplateFileBelongsToTakeover(tc.row, takeoverA); got != tc.want { t.Fatalf("unexpected result: got %v want %v", got, tc.want) } }) } } func TestTakeoverHandler_buildTakeoverResponseAttributes_ResolvesShiftTimeFromDB(t *testing.T) { db := openTakeoverHandlerTestDB(t) now := time.Now().UTC() category := &base.BaseCategory{ ID: uuidv7.MustBytes(), Key: base.CategoryKeyHEMS, Name: "HEMS", CreatedAt: now, UpdatedAt: now, } if err := db.Create(category).Error; err != nil { t.Fatalf("create category: %v", err) } repo := mysqlrepo.NewBaseRepository(db) baseID := uuidv7.MustBytes() row := &base.Base{ ID: baseID, BaseCategoryID: category.ID, BaseName: "Lude", BaseAbbreviation: "LOIG", DefaultStartTimeType: base.ShiftTimeTypeBMCT, DefaultEndTimeType: base.ShiftTimeTypeECET, DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", OperationalShiftTimes: []base.BaseOperationalShiftTime{ { DateStart: func() *time.Time { t := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC); return &t }(), DateEnd: func() *time.Time { t := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC); return &t }(), ShiftStart: "09:00:00", ShiftEnd: "18:00:00", }, }, CreatedAt: now, UpdatedAt: now, } if err := repo.CreateBase(context.Background(), row, base.CategoryKeyHEMS); err != nil { t.Fatalf("create base: %v", err) } h := &TakeoverHandler{ db: db, base: service.NewBaseService(repo), } flightRow := &flight.Flight{ ID: uuidv7.MustBytes(), Date: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC), } takeoverRow := &takeoverdomain.TakeoverAc{ ID: uuidv7.MustBytes(), BaseID: baseID, Base: &base.Base{ ID: baseID, BaseName: "Lude", BaseAbbreviation: "LOIG", }, } dutyResource := responsedto.DutyRosterResource{ Attributes: responsedto.DutyRosterAttributes{ DutyDate: "2026-06-12", Detail: responsedto.DutyRosterDetailsResponse{}, }, } attrs := h.buildTakeoverResponseAttributes(nil, takeoverRow, flightRow, nil, nil, dutyResource) resolved := h.resolveTakeoverShiftTime(nil, baseID, flightRow.Date) if resolved.ShiftStart == "" || resolved.ShiftEnd == "" { t.Fatalf("expected resolved shift time from takeover handler") } wantStart, wantEnd, err := service.ResolveBaseShiftRangeTx(db, baseID, flightRow.Date) if err != nil { t.Fatalf("resolve shift window: %v", err) } if attrs.Base == nil { t.Fatalf("expected base in takeover response") } if attrs.Base.ShiftTime.ShiftStart != wantStart || attrs.Base.ShiftTime.ShiftEnd != wantEnd { t.Fatalf("unexpected base shift time: got=%#v want=%s %s", attrs.Base.ShiftTime, wantStart, wantEnd) } if attrs.ShiftStart != wantStart || attrs.ShiftEnd != wantEnd { t.Fatalf("unexpected top-level shift time: %#v %#v", attrs.ShiftStart, attrs.ShiftEnd) } } func TestBuildDutyRosterResourceFromTakeoverRows_PreservesCalendarDutyDate(t *testing.T) { jakarta := time.FixedZone("UTC+7", 7*60*60) flightRow := &flight.Flight{ ID: uuidv7.MustBytes(), TakeoverAcID: uuidv7.MustBytes(), Date: time.Date(2026, time.June, 1, 0, 0, 0, 0, jakarta), } resource := buildDutyRosterResourceFromTakeoverRows( responsedto.DutyRosterResource{ Attributes: responsedto.DutyRosterAttributes{ Detail: responsedto.DutyRosterDetailsResponse{}, }, }, nil, flightRow, ) if got, want := resource.Attributes.DutyDate, "2026-06-01"; got != want { t.Fatalf("unexpected duty date: got %s want %s", got, want) } } func TestCalendarDateString_PreservesCalendarDate(t *testing.T) { jakarta := time.FixedZone("UTC+7", 7*60*60) value := time.Date(2026, time.June, 1, 0, 0, 0, 0, jakarta) if got, want := calendarDateString(value), "2026-06-01"; got != want { t.Fatalf("unexpected calendar date string: got %s want %s", got, want) } } func TestTakeoverHandler_resolveTakeoverReserveRow_PrefersPreloadedReserveAc(t *testing.T) { h := &TakeoverHandler{} expected := &reserveac.ReserveAc{ID: uuidv7.MustBytes(), InspectionID: uuidv7.MustBytes()} takeoverRow := &takeoverdomain.TakeoverAc{ReserveAc: expected} flightRow := &flight.Flight{ReserveAcID: uuidv7.MustBytes()} got := h.resolveTakeoverReserveRow(nil, takeoverRow, flightRow) if got != expected { t.Fatalf("expected preloaded reserve_ac to win, got %#v", got) } } func openTakeoverHandlerTestDB(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:takeover_handler_test_%d?mode=memory&cache=shared", time.Now().UnixNano()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{NowFunc: func() time.Time { return time.Now().UTC() }}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&base.BaseCategory{}, &base.Base{}, &base.BaseOperationalShiftTime{}, &base.BaseContactRole{}); err != nil { t.Fatalf("auto migrate: %v", err) } return db }