package service import ( "fmt" "path/filepath" "strings" "testing" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" "wucher/internal/domain/base" beforeflightinspection "wucher/internal/domain/before_flight_inspection" filemanager "wucher/internal/domain/file_manager" flightinspection "wucher/internal/domain/flight_inspection" flightprepcheck "wucher/internal/domain/flight_prep_check" helicopterfile "wucher/internal/domain/helicopter_file" "wucher/internal/repository/mysql" "wucher/internal/shared/pkg/uuidv7" requestdto "wucher/internal/transport/http/dto/request" ) func openTakeoverServiceTestDB(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", filepath.Join(t.TempDir(), "takeover.db")) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&base.BaseCategory{}, &base.Base{}, &base.BaseOperationalShiftTime{}); err != nil { t.Fatalf("auto migrate base models: %v", err) } if err := db.AutoMigrate(&flightinspection.FlightInspection{}, &beforeflightinspection.BeforeFlightInspection{}, &flightprepcheck.FlightPrepCheck{}); err != nil { t.Fatalf("auto migrate inspection models: %v", err) } if err := db.Exec(`CREATE TABLE helicopter_files ( id BLOB PRIMARY KEY, section TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 1, is_mandatory INTEGER NOT NULL DEFAULT 0, helicopter_id BLOB NOT NULL, file_attachment_id BLOB, source_file_id BLOB, created_at DATETIME, created_by BLOB, updated_at DATETIME, updated_by BLOB )`).Error; err != nil { t.Fatalf("create helicopter_files table: %v", err) } if err := db.Exec(`CREATE TABLE flight_inspection_file_checklists ( id BLOB PRIMARY KEY, is_done INTEGER NOT NULL DEFAULT 0, helicopter_file_id BLOB NOT NULL, flight_inspection_id BLOB NOT NULL, created_at DATETIME, created_by BLOB, updated_at DATETIME, updated_by BLOB, UNIQUE (flight_inspection_id, helicopter_file_id) )`).Error; err != nil { t.Fatalf("create flight_inspection_file_checklists table: %v", err) } return db } // newChecklistTestService builds a TakeoverService wired with the concrete repository so // the checklist upsert can load template files (DB access lives in the repository layer). func newChecklistTestService(db *gorm.DB) *TakeoverService { return NewTakeoverService(db).WithChecklistTemplateRepository(mysql.NewTakeoverRepository(db)) } func TestValidateTakeoverInspectionChecklistsAllowsEmptySections(t *testing.T) { if err := validateTakeoverInspectionChecklists(&requestdto.ReserveAcInspectionCreateAttributes{}); err != nil { t.Fatalf("expected empty sections to be allowed, got %v", err) } } func TestUpsertTakeoverInspectionTxAllowsMissingFleetStatusFileOnUpdate(t *testing.T) { db := openTakeoverServiceTestDB(t) now := time.Now().UTC() helicopterID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() inspectionDate := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC) if err := db.Create(&flightinspection.FlightInspection{ ID: inspectionID, InspectionDate: inspectionDate, Status: flightinspection.StatusDraft, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create inspection: %v", err) } req := &requestdto.ReserveAcInspectionCreateAttributes{ InspectionDate: "2026-06-24", Before: requestdto.ReserveAcBeforeInspectionAttributes{ OilEngineNR1Checked: boolPtr(true), OilEngineNR2Checked: boolPtr(true), HydraulicLHChecked: boolPtr(true), HydraulicRHChecked: boolPtr(true), OilTransmissionMGBChecked: boolPtr(true), OilTransmissionIGBChecked: boolPtr(false), OilTransmissionTGBChecked: boolPtr(true), FuelAmount: floatPtr(1), FuelUnit: stringPtr(beforeflightinspection.FuelUnitLT), FuelAddedAmount: floatPtr(0), }, Prepare: requestdto.ReserveAcPrepareInspectionAttributes{ NotamBriefing: boolPtr(true), WeatherBriefing: boolPtr(true), OperationalFlightPlan: boolPtr(false), }, } gotID, err := newChecklistTestService(db).upsertTakeoverInspectionTx(db, nil, inspectionID, req, helicopterID, uuidv7.MustBytes()) if err != nil { t.Fatalf("expected update inspection without fleet_status_file to pass, got %v", err) } if string(gotID) != string(inspectionID) { t.Fatalf("unexpected inspection id: %x", gotID) } } func TestUpsertTakeoverInspectionTxAllowsMissingFileChecklistsOnUpdate(t *testing.T) { db := openTakeoverServiceTestDB(t) now := time.Now().UTC() helicopterID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() inspectionDate := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC) if err := db.Create(&flightinspection.FlightInspection{ ID: inspectionID, InspectionDate: inspectionDate, Status: flightinspection.StatusDraft, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create inspection: %v", err) } req := &requestdto.ReserveAcInspectionCreateAttributes{ InspectionDate: "2026-06-24", Before: requestdto.ReserveAcBeforeInspectionAttributes{ OilEngineNR1Checked: boolPtr(true), OilEngineNR2Checked: boolPtr(true), HydraulicLHChecked: boolPtr(true), HydraulicRHChecked: boolPtr(true), OilTransmissionMGBChecked: boolPtr(true), OilTransmissionIGBChecked: boolPtr(false), OilTransmissionTGBChecked: boolPtr(true), FuelAmount: floatPtr(1), FuelUnit: stringPtr(beforeflightinspection.FuelUnitLT), FuelAddedAmount: floatPtr(0), }, Prepare: requestdto.ReserveAcPrepareInspectionAttributes{ NotamBriefing: boolPtr(true), WeatherBriefing: boolPtr(true), OperationalFlightPlan: boolPtr(false), }, } gotID, err := newChecklistTestService(db).upsertTakeoverInspectionTx(db, nil, inspectionID, req, helicopterID, uuidv7.MustBytes()) if err != nil { t.Fatalf("expected update inspection without file_checklists to pass, got %v", err) } if string(gotID) != string(inspectionID) { t.Fatalf("unexpected inspection id: %x", gotID) } } func TestEnsureHelicopterExistsTxDoesNotRequireDeletedAtColumn(t *testing.T) { db := openTakeoverServiceTestDB(t) helicopterID := uuidv7.MustBytes() if err := db.Exec(`CREATE TABLE helicopters ( id BLOB PRIMARY KEY )`).Error; err != nil { t.Fatalf("create helicopters table: %v", err) } if err := db.Exec(fmt.Sprintf(`INSERT INTO helicopters (id) VALUES (X'%x')`, helicopterID)).Error; err != nil { t.Fatalf("seed helicopter: %v", err) } if err := ensureHelicopterExistsTx(db, helicopterID); err != nil { t.Fatalf("expected helicopter existence check to pass without deleted_at column, got %v", err) } if err := ensureHelicopterExistsTx(db, uuidv7.MustBytes()); err == nil { t.Fatalf("expected missing helicopter to fail") } } func TestUpsertTakeoverInspectionTxAllowsEmptyFileChecklistsOnUpdate(t *testing.T) { db := openTakeoverServiceTestDB(t) now := time.Now().UTC() helicopterID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() inspectionDate := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC) if err := db.Create(&flightinspection.FlightInspection{ ID: inspectionID, InspectionDate: inspectionDate, Status: flightinspection.StatusDraft, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create inspection: %v", err) } templateFile := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: helicopterfile.SectionBeforeFirstFlightInspection, Position: 1, IsMandatory: true, HelicopterID: helicopterID, FileAttachmentID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, } if err := db.Create(&templateFile).Error; err != nil { t.Fatalf("create helicopter file template: %v", err) } req := &requestdto.ReserveAcInspectionCreateAttributes{ InspectionDate: "2026-06-24", Before: requestdto.ReserveAcBeforeInspectionAttributes{ OilEngineNR1Checked: boolPtr(true), OilEngineNR2Checked: boolPtr(true), HydraulicLHChecked: boolPtr(true), HydraulicRHChecked: boolPtr(true), OilTransmissionMGBChecked: boolPtr(true), OilTransmissionIGBChecked: boolPtr(false), OilTransmissionTGBChecked: boolPtr(true), FuelAmount: floatPtr(1), FuelUnit: stringPtr(beforeflightinspection.FuelUnitLT), FuelAddedAmount: floatPtr(0), FileChecklist: []requestdto.ReserveAcFileChecklistItem{}, }, Prepare: requestdto.ReserveAcPrepareInspectionAttributes{ NotamBriefing: boolPtr(true), WeatherBriefing: boolPtr(true), OperationalFlightPlan: boolPtr(false), FileChecklist: []requestdto.ReserveAcFileChecklistItem{}, }, } gotID, err := newChecklistTestService(db).upsertTakeoverInspectionTx(db, nil, inspectionID, req, helicopterID, uuidv7.MustBytes()) if err != nil { t.Fatalf("expected update inspection with empty file_checklists to pass, got %v", err) } if string(gotID) != string(inspectionID) { t.Fatalf("unexpected inspection id: %x", gotID) } var checklistCount int64 if err := db.Table("flight_inspection_file_checklists").Count(&checklistCount).Error; err != nil { t.Fatalf("count file checklists: %v", err) } if checklistCount != 0 { t.Fatalf("expected no checklist rows to be created on empty update, got %d", checklistCount) } } func boolPtr(v bool) *bool { return &v } func floatPtr(v float64) *float64 { return &v } func stringPtr(v string) *string { return &v } func TestUpsertTakeoverChecklistItemsTxAllowsEmptyTemplateAndEmptyPayload(t *testing.T) { db := openTakeoverServiceTestDB(t) inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() err := newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", nil, uuidv7.MustBytes()) if err != nil { t.Fatalf("expected empty template and empty payload to pass, got %v", err) } } func TestUpsertTakeoverChecklistItemsTxRejectsItemsWhenTemplateMissing(t *testing.T) { db := openTakeoverServiceTestDB(t) inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() fileID := uuidv7.MustBytes() fileIDStr, _ := uuidv7.BytesToString(fileID) err := newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", []requestdto.ReserveAcFileChecklistItem{{ HelicopterFileID: fileIDStr, IsDone: true, }}, uuidv7.MustBytes()) if err == nil { t.Fatal("expected error when template is missing and payload has items") } if !strings.Contains(err.Error(), "inspection checklist template is empty for section fpwb") { t.Fatalf("unexpected error: %v", err) } } func TestUpsertTakeoverChecklistItemsTxAllowsOptionalFalseButRequiresMandatoryTrue(t *testing.T) { db := openTakeoverServiceTestDB(t) inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() now := time.Now().UTC() mandatoryFile := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: "fpwb", Position: 1, IsMandatory: true, HelicopterID: helicopterID, FileAttachmentID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, } optionalFile := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: "fpwb", Position: 2, IsMandatory: false, HelicopterID: helicopterID, FileAttachmentID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, } if err := db.Create(&mandatoryFile).Error; err != nil { t.Fatalf("create mandatory file: %v", err) } if err := db.Create(&optionalFile).Error; err != nil { t.Fatalf("create optional file: %v", err) } mandatoryIDStr, _ := uuidv7.BytesToString(mandatoryFile.ID) optionalIDStr, _ := uuidv7.BytesToString(optionalFile.ID) err := newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: mandatoryIDStr, IsDone: true}, {HelicopterFileID: optionalIDStr, IsDone: false}, }, uuidv7.MustBytes()) if err != nil { t.Fatalf("expected optional false to pass, got %v", err) } err = newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: mandatoryIDStr, IsDone: false}, {HelicopterFileID: optionalIDStr, IsDone: false}, }, uuidv7.MustBytes()) if err == nil { t.Fatal("expected mandatory false to fail") } if !strings.Contains(err.Error(), "must be fully checked") { t.Fatalf("unexpected error: %v", err) } } func TestUpsertTakeoverChecklistItemsTxAllowsPartialChecklist(t *testing.T) { db := openTakeoverServiceTestDB(t) inspectionID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() now := time.Now().UTC() mandatoryFile := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: "fpwb", Position: 1, IsMandatory: true, HelicopterID: helicopterID, FileAttachmentID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, } optionalFile := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: "fpwb", Position: 2, IsMandatory: false, HelicopterID: helicopterID, FileAttachmentID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, } if err := db.Create(&mandatoryFile).Error; err != nil { t.Fatalf("create mandatory file: %v", err) } if err := db.Create(&optionalFile).Error; err != nil { t.Fatalf("create optional file: %v", err) } optionalIDStr, _ := uuidv7.BytesToString(optionalFile.ID) // Submit ONLY the optional file; the unedited mandatory catalog file may be // omitted from the checklist (no more "must include all helicopter files"). if err := newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: optionalIDStr, IsDone: true}, }, uuidv7.MustBytes()); err != nil { t.Fatalf("expected partial checklist omitting non-edited files to pass, got %v", err) } // Only the submitted item is recorded. var count int64 if err := db.Table("flight_inspection_file_checklists").Where("flight_inspection_id = ?", inspectionID).Count(&count).Error; err != nil { t.Fatalf("count checklist rows: %v", err) } if count != 1 { t.Fatalf("expected exactly 1 checklist row, got %d", count) } // A mandatory file that IS submitted but not done is still rejected. mandatoryIDStr, _ := uuidv7.BytesToString(mandatoryFile.ID) if err := newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: mandatoryIDStr, IsDone: false}, }, uuidv7.MustBytes()); err == nil { t.Fatal("expected submitted-but-undone mandatory file to be rejected") } } func TestUpsertTakeoverChecklistItemsTxExcludesTemplateDerivedFiles(t *testing.T) { db := openTakeoverServiceTestDB(t) if err := db.AutoMigrate(&filemanager.File{}); err != nil { t.Fatalf("migrate files: %v", err) } now := time.Now().UTC() helicopterID := uuidv7.MustBytes() inspectionID := uuidv7.MustBytes() templateFileID := uuidv7.MustBytes() derivedFileID := uuidv7.MustBytes() folderID := uuidv7.MustBytes() if err := db.Create(&filemanager.File{ID: templateFileID, FolderID: folderID, Name: "template.docx", ObjectKey: "k1", IsTemplate: true}).Error; err != nil { t.Fatalf("create template file: %v", err) } // A document created from a template (has a non-empty template_uuid). if err := db.Create(&filemanager.File{ID: derivedFileID, FolderID: folderID, Name: "from-template.docx", ObjectKey: "k2", TemplateUUID: uuidv7.MustBytes()}).Error; err != nil { t.Fatalf("create derived file: %v", err) } templateRow := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: "fpwb", Position: 1, HelicopterID: helicopterID, SourceFileID: templateFileID, CreatedAt: now, UpdatedAt: now, } derivedRow := helicopterfile.HelicopterFile{ ID: uuidv7.MustBytes(), Section: "fpwb", Position: 2, HelicopterID: helicopterID, SourceFileID: derivedFileID, CreatedAt: now, UpdatedAt: now, } if err := db.Create(&templateRow).Error; err != nil { t.Fatalf("create template row: %v", err) } if err := db.Create(&derivedRow).Error; err != nil { t.Fatalf("create derived row: %v", err) } templateIDStr, _ := uuidv7.BytesToString(templateRow.ID) // Payload includes only the real template; the template-derived row must not be // required by the checklist completeness check. if err := newChecklistTestService(db).upsertTakeoverChecklistItemsTx(db, inspectionID, helicopterID, "fpwb", []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: templateIDStr, IsDone: true}, }, uuidv7.MustBytes()); err != nil { t.Fatalf("expected success when only the template is checked, got %v", err) } } func TestValidateTakeoverEditedTemplateFilesTx(t *testing.T) { db := openTakeoverServiceTestDB(t) if err := db.AutoMigrate(&filemanager.File{}); err != nil { t.Fatalf("migrate files: %v", err) } svc := newChecklistTestService(db) takeoverID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() folderID := uuidv7.MustBytes() now := time.Now().UTC() // template_uuid on a from-template document == the source HelicopterFile.ID // that a checklist item's helicopter_file_id references. editedTemplateA := uuidv7.MustBytes() editedTemplateB := uuidv7.MustBytes() if err := db.Create(&helicopterfile.HelicopterFile{ ID: editedTemplateA, Section: helicopterfile.SectionFlightPreparationAndWB, HelicopterID: helicopterID, SourceFileID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create checklist template A: %v", err) } if err := db.Create(&helicopterfile.HelicopterFile{ ID: editedTemplateB, Section: helicopterfile.SectionFlightPreparationAndWB, HelicopterID: helicopterID, SourceFileID: uuidv7.MustBytes(), CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create checklist template B: %v", err) } // Two documents edited/created from a template for this takeover. if err := db.Create(&filemanager.File{ID: uuidv7.MustBytes(), FolderID: folderID, Name: "a.docx", ObjectKey: "a", TemplateUUID: editedTemplateA, TakeoverID: takeoverID}).Error; err != nil { t.Fatalf("create edited file A: %v", err) } if err := db.Create(&filemanager.File{ID: uuidv7.MustBytes(), FolderID: folderID, Name: "b.docx", ObjectKey: "b", TemplateUUID: editedTemplateB, TakeoverID: takeoverID}).Error; err != nil { t.Fatalf("create edited file B: %v", err) } // A from-template document for a different takeover must be ignored. if err := db.Create(&filemanager.File{ID: uuidv7.MustBytes(), FolderID: folderID, Name: "other.docx", ObjectKey: "o", TemplateUUID: uuidv7.MustBytes(), TakeoverID: uuidv7.MustBytes()}).Error; err != nil { t.Fatalf("create other-takeover file: %v", err) } aStr, _ := uuidv7.BytesToString(editedTemplateA) bStr, _ := uuidv7.BytesToString(editedTemplateB) unrelatedStr, _ := uuidv7.BytesToString(uuidv7.MustBytes()) // 1) Checklist missing an edited file (B) -> reject. if err := svc.validateTakeoverEditedTemplateFilesTx(db, takeoverID, helicopterID, []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: aStr, IsDone: true}, }); err == nil { t.Fatalf("expected rejection when an edited file is missing from the checklist") } // 2) Edited files exist but checklist is empty -> reject. if err := svc.validateTakeoverEditedTemplateFilesTx(db, takeoverID, helicopterID, nil); err == nil { t.Fatalf("expected rejection when edited files exist but checklist is empty") } // 3) Checklist includes both edited files plus an unrelated (non-edited) // item -> success; a checklist item that was not edited is fine. if err := svc.validateTakeoverEditedTemplateFilesTx(db, takeoverID, helicopterID, []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: aStr, IsDone: true}, {HelicopterFileID: bStr, IsDone: true}, {HelicopterFileID: unrelatedStr, IsDone: false}, }); err != nil { t.Fatalf("expected success when all edited files are in the checklist, got %v", err) } // 4) No edited files for the takeover -> success regardless of checklist. if err := svc.validateTakeoverEditedTemplateFilesTx(db, uuidv7.MustBytes(), uuidv7.MustBytes(), nil); err != nil { t.Fatalf("expected success when there are no edited files, got %v", err) } // 5) A template-derived file that has already been trashed should not block // an inspection update when it is no longer part of the active takeover set. trashedTemplate := uuidv7.MustBytes() trashedTakeoverID := uuidv7.MustBytes() if err := db.Create(&filemanager.File{ ID: uuidv7.MustBytes(), FolderID: folderID, Name: "trashed.docx", ObjectKey: "trashed", TemplateUUID: trashedTemplate, TakeoverID: trashedTakeoverID, Status: filemanager.FileStatusTrashed, }).Error; err != nil { t.Fatalf("create trashed edited file: %v", err) } if err := svc.validateTakeoverEditedTemplateFilesTx(db, trashedTakeoverID, helicopterID, nil); err != nil { t.Fatalf("expected trashed edited file to be ignored, got %v", err) } } func TestValidateTakeoverEditedTemplateFilesTxAcceptsLegacySourceFileReferences(t *testing.T) { db := openTakeoverServiceTestDB(t) if err := db.AutoMigrate(&filemanager.File{}); err != nil { t.Fatalf("migrate files: %v", err) } svc := newChecklistTestService(db) takeoverID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() folderID := uuidv7.MustBytes() now := time.Now().UTC() sourceFileID := uuidv7.MustBytes() templateHeliFileID := uuidv7.MustBytes() if err := db.Create(&filemanager.File{ ID: sourceFileID, FolderID: folderID, Name: "template.xlsx", ObjectKey: "template.xlsx", }).Error; err != nil { t.Fatalf("create source template file: %v", err) } if err := db.Create(&helicopterfile.HelicopterFile{ ID: templateHeliFileID, Section: helicopterfile.SectionFlightPreparationAndWB, HelicopterID: helicopterID, SourceFileID: sourceFileID, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { t.Fatalf("create helicopter template row: %v", err) } // Legacy data may have template_uuid pointing to the source file ID instead // of the helicopter_file ID shown in the checklist payload. if err := db.Create(&filemanager.File{ ID: uuidv7.MustBytes(), FolderID: folderID, Name: "draft.xlsx", ObjectKey: "draft.xlsx", TemplateUUID: sourceFileID, TakeoverID: takeoverID, }).Error; err != nil { t.Fatalf("create legacy edited file: %v", err) } templateChecklistID, _ := uuidv7.BytesToString(templateHeliFileID) if err := svc.validateTakeoverEditedTemplateFilesTx(db, takeoverID, helicopterID, []requestdto.ReserveAcFileChecklistItem{ {HelicopterFileID: templateChecklistID, IsDone: true}, }); err != nil { t.Fatalf("expected legacy source file template_uuid to map back to checklist template, got %v", err) } } func TestValidateTakeoverEditedTemplateFilesTxIgnoresOrphanedTemplateReferences(t *testing.T) { db := openTakeoverServiceTestDB(t) if err := db.AutoMigrate(&filemanager.File{}); err != nil { t.Fatalf("migrate files: %v", err) } svc := newChecklistTestService(db) takeoverID := uuidv7.MustBytes() helicopterID := uuidv7.MustBytes() folderID := uuidv7.MustBytes() if err := db.Create(&filemanager.File{ ID: uuidv7.MustBytes(), FolderID: folderID, Name: "orphaned-draft.xlsx", ObjectKey: "orphaned-draft.xlsx", TemplateUUID: uuidv7.MustBytes(), TakeoverID: takeoverID, }).Error; err != nil { t.Fatalf("create orphaned edited file: %v", err) } if err := svc.validateTakeoverEditedTemplateFilesTx(db, takeoverID, helicopterID, nil); err != nil { t.Fatalf("expected orphaned historical template reference to be ignored, got %v", err) } } func TestResolveBaseShiftRangeTxPrefersOperationalShiftTime(t *testing.T) { db := openTakeoverServiceTestDB(t) now := time.Now().UTC() category := &base.BaseCategory{ID: uuidv7.MustBytes(), Key: base.CategoryKeyRegular, Name: "Regular", CreatedAt: now, UpdatedAt: now} if err := db.Create(category).Error; err != nil { t.Fatalf("create category: %v", err) } defaultStart := time.Date(1900, 1, 1, 9, 0, 0, 0, time.UTC) defaultEnd := time.Date(1900, 1, 1, 18, 0, 0, 0, time.UTC) baseID := uuidv7.MustBytes() row := &base.Base{ ID: baseID, BaseCategoryID: category.ID, BaseName: "Base A", DefaultShiftStart: defaultStart.Format("15:04:05"), DefaultShiftEnd: defaultEnd.Format("15:04:05"), CreatedAt: now, UpdatedAt: now, } if err := db.Create(row).Error; err != nil { t.Fatalf("create base: %v", err) } opStart := time.Date(1900, 1, 1, 9, 0, 0, 0, time.UTC) opEnd := time.Date(1900, 1, 1, 17, 0, 0, 0, time.UTC) opDate := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC) opRow := &base.BaseOperationalShiftTime{ ID: uuidv7.MustBytes(), BaseID: baseID, DateStart: &opDate, DateEnd: &opDate, ShiftStart: opStart.Format("15:04:05"), ShiftEnd: opEnd.Format("15:04:05"), CreatedAt: now, UpdatedAt: now, } if err := db.Create(opRow).Error; err != nil { t.Fatalf("create operational shift: %v", err) } start, end, err := ResolveBaseShiftRangeTx(db, baseID, time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("resolve shift: %v", err) } if start != "09:00" || end != "17:00" { t.Fatalf("unexpected operational shift: start=%s end=%s", start, end) } } func TestResolveBaseShiftRangeTxFallsBackToDefaultShiftTime(t *testing.T) { db := openTakeoverServiceTestDB(t) now := time.Now().UTC() category := &base.BaseCategory{ID: uuidv7.MustBytes(), Key: base.CategoryKeyRegular, Name: "Regular", CreatedAt: now, UpdatedAt: now} if err := db.Create(category).Error; err != nil { t.Fatalf("create category: %v", err) } defaultStart := time.Date(1900, 1, 1, 9, 0, 0, 0, time.UTC) defaultEnd := time.Date(1900, 1, 1, 18, 0, 0, 0, time.UTC) baseID := uuidv7.MustBytes() row := &base.Base{ ID: baseID, BaseCategoryID: category.ID, BaseName: "Base B", DefaultShiftStart: defaultStart.Format("15:04:05"), DefaultShiftEnd: defaultEnd.Format("15:04:05"), CreatedAt: now, UpdatedAt: now, } if err := db.Create(row).Error; err != nil { t.Fatalf("create base: %v", err) } start, end, err := ResolveBaseShiftRangeTx(db, baseID, time.Date(2026, 4, 13, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("resolve shift: %v", err) } if start != "09:00" || end != "18:00" { t.Fatalf("unexpected default shift: start=%s end=%s", start, end) } } func TestResolveBaseShiftRangeTxResolvesBMCTECETFromCoordinates(t *testing.T) { db := openTakeoverServiceTestDB(t) now := time.Now().UTC() category := &base.BaseCategory{ID: uuidv7.MustBytes(), Key: base.CategoryKeyRegular, Name: "Regular", CreatedAt: now, UpdatedAt: now} if err := db.Create(category).Error; err != nil { t.Fatalf("create category: %v", err) } baseID := uuidv7.MustBytes() row := &base.Base{ ID: baseID, BaseCategoryID: category.ID, BaseName: "Base C", Latitude: 47.3769, Longitude: 8.5417, DefaultStartTimeType: base.ShiftTimeTypeBMCT, DefaultEndTimeType: base.ShiftTimeTypeECET, CreatedAt: now, UpdatedAt: now, } if err := db.Create(row).Error; err != nil { t.Fatalf("create base: %v", err) } start, end, err := ResolveBaseShiftRangeTx(db, baseID, time.Date(2026, 4, 13, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("resolve shift: %v", err) } if strings.TrimSpace(start) == "" || strings.TrimSpace(end) == "" { t.Fatalf("expected resolved BMCT/ECET shift, got start=%q end=%q", start, end) } if start == "09:00:00" || end == "18:00:00" { t.Fatalf("expected computed twilight times, got fixed-looking values start=%s end=%s", start, end) } }