package mysql import ( "context" "fmt" "strings" "testing" "time" sqlite3 "github.com/mattn/go-sqlite3" "gorm.io/driver/sqlite" "gorm.io/gorm" dutyroster "wucher/internal/domain/duty_roster" "wucher/internal/service" "wucher/internal/shared/pkg/uuidv7" ) func openDutyRosterRepoTestDB(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:duty_roster_repo_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) } createRoster := ` CREATE TABLE duty_rosters ( id BLOB PRIMARY KEY, flight_id BLOB NULL, base_id BLOB NOT NULL, duty_date DATETIME NOT NULL, shift_start DATETIME NULL, shift_end DATETIME NULL, helicopter_id BLOB NULL, created_at DATETIME NULL, created_by BLOB NULL, updated_at DATETIME NULL, updated_by BLOB NULL, deleted_at DATETIME NULL, deleted_by BLOB NULL );` if err := db.Exec(createRoster).Error; err != nil { t.Fatalf("create duty_rosters: %v", err) } if err := db.Exec("CREATE UNIQUE INDEX idx_roster_base_date ON duty_rosters(base_id, duty_date)").Error; err != nil { t.Fatalf("create idx_roster_base_date: %v", err) } createCrew := ` CREATE TABLE duty_roster_crews ( id BLOB PRIMARY KEY, roster_id BLOB NOT NULL, takeover_ac_id BLOB NULL, user_id BLOB NULL, role_code TEXT NOT NULL, crew_type TEXT NOT NULL, name_label TEXT NULL, mobile_phone TEXT NULL, email TEXT NULL, confirm_at DATETIME NULL, date_start DATE NULL, date_end DATE NULL, shift_start DATETIME NULL, shift_end DATETIME NULL, flight_instructor INTEGER NOT NULL DEFAULT 0, line_checker INTEGER NOT NULL DEFAULT 0, supervisor INTEGER NOT NULL DEFAULT 0, examiner INTEGER NOT NULL DEFAULT 0, co_pilot INTEGER NOT NULL DEFAULT 0, created_at DATETIME NULL, created_by BLOB NULL, updated_at DATETIME NULL, updated_by BLOB NULL, deleted_at DATETIME NULL, deleted_by BLOB NULL );` if err := db.Exec(createCrew).Error; err != nil { t.Fatalf("create duty_roster_crews: %v", err) } registerSQLiteTimeFormatCompat(t, db) return db } func registerSQLiteTimeFormatCompat(t *testing.T, db *gorm.DB) { t.Helper() sqlDB, err := db.DB() if err != nil { t.Fatalf("db.DB: %v", err) } conn, err := sqlDB.Conn(context.Background()) if err != nil { t.Fatalf("sqlDB.Conn: %v", err) } defer conn.Close() if err := conn.Raw(func(driverConn any) error { raw, ok := driverConn.(*sqlite3.SQLiteConn) if !ok { return fmt.Errorf("unexpected sqlite conn type %T", driverConn) } return raw.RegisterFunc("TIME_FORMAT", func(v any, pattern string) string { if pattern != "%H:%i:%s" { return "" } var rawVal string switch x := v.(type) { case time.Time: return x.UTC().Format("15:04:05") case []byte: rawVal = string(x) case string: rawVal = x default: rawVal = fmt.Sprint(x) } rawVal = strings.TrimSpace(rawVal) if rawVal == "" { return "" } layouts := []string{ "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05.999999999", "2006-01-02 15:04:05", time.RFC3339, "15:04:05", } for _, layout := range layouts { if tm, err := time.Parse(layout, rawVal); err == nil { return tm.UTC().Format("15:04:05") } } if len(rawVal) >= 8 { return rawVal[len(rawVal)-8:] } return "" }, true) }); err != nil { t.Fatalf("register TIME_FORMAT: %v", err) } } func dutyRosterTestCategoryIDs() (regularID, hemsID []byte) { return []byte("catregular000001"), []byte("cathems000000001") } func TestDutyRosterRepositoryUpdate_ReplaceSameUserAppendDifferentUser(t *testing.T) { db := openDutyRosterRepoTestDB(t) repo := NewDutyRosterRepository(db) svc := service.NewDutyRosterService(repo) ctx := context.Background() baseID := uuidv7.MustBytes() actor := uuidv7.MustBytes() userMain := uuidv7.MustBytes() userAdditional := uuidv7.MustBytes() date := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) row := &dutyroster.DutyRoster{ BaseID: baseID, DutyDate: date, CreatedBy: actor, UpdatedBy: actor, } if err := svc.Create(ctx, row, nil); err != nil { t.Fatalf("seed create roster: %v", err) } if err := db.Create(&dutyroster.DutyRosterCrew{ RosterID: row.ID, UserID: userMain, RoleCode: "pilot", CrewType: "main", CreatedBy: actor, UpdatedBy: actor, }).Error; err != nil { t.Fatalf("seed old crew: %v", err) } updateRow := &dutyroster.DutyRoster{ ID: row.ID, BaseID: baseID, DutyDate: date, UpdatedBy: actor, } updateCrews := []dutyroster.DutyRosterCrew{ { UserID: userMain, RoleCode: "pilot", CrewType: "main", }, { UserID: userAdditional, RoleCode: "pilot", CrewType: "additional", }, } if err := svc.Update(ctx, updateRow, updateCrews); err != nil { t.Fatalf("update roster: %v", err) } var activeMain []dutyroster.DutyRosterCrew if err := db.Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main' AND deleted_at IS NULL", row.ID, userMain). Find(&activeMain).Error; err != nil { t.Fatalf("query active main: %v", err) } if len(activeMain) != 1 { t.Fatalf("expected 1 active main crew, got %d", len(activeMain)) } var historicalMain int64 if err := db.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main'", row.ID, userMain). Count(&historicalMain).Error; err != nil { t.Fatalf("count main history: %v", err) } if historicalMain != 2 { t.Fatalf("expected 2 historical main rows (old deleted + new active), got %d", historicalMain) } var activeAdditional int64 if err := db.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'additional' AND deleted_at IS NULL", row.ID, userAdditional). Count(&activeAdditional).Error; err != nil { t.Fatalf("count active additional: %v", err) } if activeAdditional != 1 { t.Fatalf("expected appended additional crew, got %d", activeAdditional) } } func TestDutyRosterRepositoryUpdate_RangeShrinkRemovesOutOfRangeAssignments(t *testing.T) { db := openDutyRosterRepoTestDB(t) repo := NewDutyRosterRepository(db) svc := service.NewDutyRosterService(repo) ctx := context.Background() baseID := uuidv7.MustBytes() actor := uuidv7.MustBytes() userID := uuidv7.MustBytes() d20 := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) d21 := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC) d22 := time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC) row20 := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: d20, CreatedBy: actor, UpdatedBy: actor} row21 := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: d21, CreatedBy: actor, UpdatedBy: actor} row22 := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: d22, CreatedBy: actor, UpdatedBy: actor} if err := svc.Create(ctx, row20, nil); err != nil { t.Fatalf("create roster d20: %v", err) } if err := svc.Create(ctx, row21, nil); err != nil { t.Fatalf("create roster d21: %v", err) } if err := svc.Create(ctx, row22, nil); err != nil { t.Fatalf("create roster d22: %v", err) } startOld := d20 endOld := d22 seedCrew := func(rosterID []byte) { if err := db.Create(&dutyroster.DutyRosterCrew{ RosterID: rosterID, UserID: userID, RoleCode: "pilot", CrewType: "main", DateStart: &startOld, DateEnd: &endOld, CreatedBy: actor, UpdatedBy: actor, }).Error; err != nil { t.Fatalf("seed crew: %v", err) } } seedCrew(row20.ID) seedCrew(row21.ID) seedCrew(row22.ID) startNew := d20 endNew := d21 updateRow := &dutyroster.DutyRoster{ ID: row20.ID, BaseID: baseID, DutyDate: d20, UpdatedBy: actor, } updateCrews := []dutyroster.DutyRosterCrew{ { UserID: userID, RoleCode: "pilot", CrewType: "main", DateStart: &startNew, DateEnd: &endNew, }, } if err := svc.Update(ctx, updateRow, updateCrews); err != nil { t.Fatalf("update range shrink: %v", err) } var active22 int64 if err := db.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main' AND deleted_at IS NULL", row22.ID, userID). Count(&active22).Error; err != nil { t.Fatalf("count active d22: %v", err) } if active22 != 1 { t.Fatalf("expected active assignment to remain on 2026-03-22 after shrink, got %d", active22) } var active21 int64 if err := db.Model(&dutyroster.DutyRosterCrew{}). Where("roster_id = ? AND user_id = ? AND role_code = 'pilot' AND crew_type = 'main' AND deleted_at IS NULL", row21.ID, userID). Count(&active21).Error; err != nil { t.Fatalf("count active d21: %v", err) } if active21 != 1 { t.Fatalf("expected active assignment remains on 2026-03-21, got %d", active21) } } func openDutyRosterRepoCoverageDB(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:duty_roster_repo_cov_%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) } stmts := []string{ `CREATE TABLE duty_rosters ( id BLOB PRIMARY KEY, base_id BLOB NOT NULL, duty_date DATETIME NOT NULL, shift_start DATETIME NULL, shift_end DATETIME NULL, helicopter_id BLOB NULL, created_at DATETIME NULL, created_by BLOB NULL, updated_at DATETIME NULL, updated_by BLOB NULL, deleted_at DATETIME NULL, deleted_by BLOB NULL );`, `CREATE UNIQUE INDEX idx_roster_base_date ON duty_rosters(base_id, duty_date);`, `CREATE TABLE duty_roster_crews ( id BLOB PRIMARY KEY, roster_id BLOB NOT NULL, takeover_ac_id BLOB NULL, user_id BLOB NULL, role_code TEXT NOT NULL, crew_type TEXT NOT NULL, name_label TEXT NULL, mobile_phone TEXT NULL, email TEXT NULL, confirm_at DATETIME NULL, date_start DATE NULL, date_end DATE NULL, shift_start DATETIME NULL, shift_end DATETIME NULL, flight_instructor INTEGER NOT NULL DEFAULT 0, line_checker INTEGER NOT NULL DEFAULT 0, supervisor INTEGER NOT NULL DEFAULT 0, examiner INTEGER NOT NULL DEFAULT 0, co_pilot INTEGER NOT NULL DEFAULT 0, created_at DATETIME NULL, created_by BLOB NULL, updated_at DATETIME NULL, updated_by BLOB NULL, deleted_at DATETIME NULL, deleted_by BLOB NULL );`, "CREATE TABLE base_categories (\n\t\t\tid BLOB PRIMARY KEY,\n\t\t\t`key` TEXT UNIQUE,\n\t\t\tname TEXT\n\t\t);", `CREATE TABLE bases ( id BLOB PRIMARY KEY, base_category_id BLOB NOT NULL, base TEXT, base_abbreviation TEXT, sortkey INTEGER NULL, is_active BOOLEAN DEFAULT 1, default_shift_start TEXT NULL, default_shift_end TEXT NULL, default_shift_time TEXT );`, `CREATE TABLE helicopters ( id BLOB PRIMARY KEY, identifier TEXT, designation TEXT );`, `CREATE TABLE roles ( id BLOB PRIMARY KEY, name TEXT );`, `CREATE TABLE users ( id BLOB PRIMARY KEY, first_name TEXT, last_name TEXT, mobile_phone TEXT, email TEXT, role_id BLOB NULL );`, } for _, stmt := range stmts { if err := db.Exec(stmt).Error; err != nil { t.Fatalf("create stmt failed: %v\nsql=%s", err, stmt) } } regularCategoryID, hemsCategoryID := dutyRosterTestCategoryIDs() asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) } if err := db.Exec( fmt.Sprintf( "INSERT INTO base_categories(id, `key`, name) VALUES(%s, 'regular', 'Regular'),(%s, 'hems', 'HEMS')", asBlob(regularCategoryID), asBlob(hemsCategoryID), ), ).Error; err != nil { t.Fatalf("seed base_categories: %v", err) } registerSQLiteTimeFormatCompat(t, db) return db } func TestDutyRosterRepository_ReadListAndDeleteFlows(t *testing.T) { db := openDutyRosterRepoCoverageDB(t) repo := NewDutyRosterRepository(db) ctx := context.Background() hemsBaseID := []byte("hemsbase00000001") baseID := []byte("basebase00000001") regularCategoryID, hemsCategoryID := dutyRosterTestCategoryIDs() heliID := []byte("heliheli00000001") roleID := []byte("rolerole00000001") userID := []byte("useruser00000001") rosterID := []byte("rosterro00000001") crewID := []byte("crewcrew00000001") now := time.Now().UTC() dutyDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) mustExec := func(sql string, args ...any) { if err := db.Exec(sql, args...).Error; err != nil { t.Fatalf("exec failed: %v, sql=%s", err, sql) } } asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) } mustExec(fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, is_active, default_shift_time) VALUES(%s, %s, ?, ?, 1, 1, ?)`, asBlob(hemsBaseID), asBlob(hemsCategoryID)), "HEMS A", "HA", "06:00-21:00") mustExec(fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, is_active, default_shift_time) VALUES(%s, %s, ?, ?, 1, 1, ?)`, asBlob(baseID), asBlob(regularCategoryID)), "BASE A", "BA", "07:00-19:00") mustExec(fmt.Sprintf(`INSERT INTO helicopters(id, identifier, designation) VALUES(%s, ?, ?)`, asBlob(heliID)), "H145", "H145") mustExec(fmt.Sprintf(`INSERT INTO roles(id, name) VALUES(%s, ?)`, asBlob(roleID)), "pilot") mustExec(fmt.Sprintf(`INSERT INTO users(id, first_name, last_name, mobile_phone, email, role_id) VALUES(%s, ?, ?, ?, ?, %s)`, asBlob(userID), asBlob(roleID)), "Pilot", "One", "+62000", "pilot@example.com") mustExec(fmt.Sprintf(`INSERT INTO duty_rosters(id, base_id, duty_date, shift_start, shift_end, helicopter_id, created_at, updated_at) VALUES(%s, %s, ?, ?, ?, %s, ?, ?)`, asBlob(rosterID), asBlob(hemsBaseID), asBlob(heliID)), dutyDate, "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now) mustExec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, date_start, date_end, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, %s, 'pilot', 'main', '', '', '', ?, ?, ?, ?, ?, ?)`, asBlob(crewID), asBlob(rosterID), asBlob(userID)), dutyDate, dutyDate, "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now) if _, err := repo.GetRosterByID(ctx, rosterID); err != nil { t.Fatalf("GetRosterByID: %v", err) } if _, err := repo.LockRosterByID(ctx, rosterID); err != nil { t.Fatalf("LockRosterByID: %v", err) } if _, err := repo.ListActiveCrewsByRosterID(ctx, rosterID); err != nil { t.Fatalf("ListActiveCrewsByRosterID: %v", err) } if _, err := repo.FindRosterIDsByBaseDate(ctx, hemsBaseID, dutyDate); err != nil { t.Fatalf("FindRosterIDsByBaseDate: %v", err) } rows, err := repo.MatchCrewByFilter(ctx, [][]byte{rosterID}, dutyroster.DutyRosterCrew{ RoleCode: "pilot", CrewType: "main", UserID: userID, }) if err != nil { t.Fatalf("MatchCrewByFilter: %v", err) } if len(rows) == 0 { t.Fatalf("expected matched rows") } if err := repo.SoftDeleteCrewRows(ctx, rows, uuidv7.MustBytes()); err != nil { t.Fatalf("SoftDeleteCrewRows: %v", err) } if _, err := repo.GetBaseDefaultShift(ctx, hemsBaseID, "hems"); err != nil { t.Fatalf("GetBaseDefaultShift hems: %v", err) } if _, err := repo.GetBaseDefaultShift(ctx, baseID, "base"); err != nil { t.Fatalf("GetBaseDefaultShift base: %v", err) } if _, err := repo.GetHeaderByID(ctx, rosterID); err != nil { t.Fatalf("GetHeaderByID: %v", err) } if _, err := repo.GetHeaderByIDByBaseType(ctx, rosterID, "base"); err != nil { t.Fatalf("GetHeaderByIDByBaseType base: %v", err) } if _, err := repo.GetCrewsByRosterIDs(ctx, [][]byte{rosterID}); err != nil { t.Fatalf("GetCrewsByRosterIDs: %v", err) } if _, err := repo.ListHeadersByRange(ctx, hemsBaseID, dutyDate.AddDate(0, 0, -1), dutyDate.AddDate(0, 0, 1)); err != nil { t.Fatalf("ListHeadersByRange: %v", err) } if _, err := repo.ListHeadersByRangeByBaseType(ctx, baseID, dutyDate.AddDate(0, 0, -1), dutyDate.AddDate(0, 0, 1), "base"); err != nil { t.Fatalf("ListHeadersByRangeByBaseType: %v", err) } if _, err := repo.ListHEMSBases(ctx, nil); err != nil { t.Fatalf("ListHEMSBases: %v", err) } if _, err := repo.ListBasesByType(ctx, nil, "base"); err != nil { t.Fatalf("ListBasesByType: %v", err) } if err := repo.SoftDeleteCrewsByRosterID(ctx, rosterID, uuidv7.MustBytes()); err != nil { t.Fatalf("SoftDeleteCrewsByRosterID: %v", err) } if err := repo.SoftDeleteHeader(ctx, rosterID, uuidv7.MustBytes()); err != nil { t.Fatalf("SoftDeleteHeader: %v", err) } } func TestDutyRosterRepository_HelperCoverage(t *testing.T) { if key := rosterBaseCategoryKey("base"); key != "regular" { t.Fatalf("unexpected base category key") } if key := rosterBaseCategoryKey("hems"); key != "hems" { t.Fatalf("unexpected hems category key") } if got := dateOnlyUTC(time.Date(2026, 3, 20, 9, 10, 11, 0, time.FixedZone("X", 7*3600))); got.Hour() != 0 { t.Fatalf("expected date only UTC, got %v", got) } if err := softDeleteCrewByIDs(nil, nil, time.Now().UTC(), nil); err != nil { t.Fatalf("softDeleteCrewByIDs empty should be nil, got %v", err) } } func TestDutyRosterRepository_HelperCoverageExtra(t *testing.T) { db := openDutyRosterRepoCoverageDB(t) repo := NewDutyRosterRepository(db) ctx := context.Background() if _, err := repo.GetCrewsByRosterIDs(ctx, nil); err != nil { t.Fatalf("GetCrewsByRosterIDs empty should not error: %v", err) } if _, err := repo.ListBasesByType(ctx, nil, "unknown"); err != nil { t.Fatalf("ListBasesByType unknown should not error: %v", err) } if _, err := repo.FindRosterIDByBaseDate(ctx, []byte("base-does-not-exist"), time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)); err != nil { t.Fatalf("FindRosterIDByBaseDate empty should not error: %v", err) } if _, err := repo.MatchCrewByFilter(ctx, nil, dutyroster.DutyRosterCrew{RoleCode: "pilot"}); err != nil { t.Fatalf("MatchCrewByFilter empty rosterIDs should not error: %v", err) } if err := softDeleteCrewByFilter(db, nil, dutyroster.DutyRosterCrew{RoleCode: "pilot"}, time.Now().UTC(), uuidv7.MustBytes()); err != nil { t.Fatalf("softDeleteCrewByFilter empty rosterIDs should not error: %v", err) } rosterID := []byte("roster-softdel-01") userID := []byte("user-softdel-001") now := time.Now().UTC() asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) } if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_rosters(id, base_id, duty_date, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, ?, ?, ?, ?, ?)`, asBlob(rosterID), asBlob([]byte("hems-base-softdel"))), time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now).Error; err != nil { t.Fatalf("seed roster for softDeleteCrewByFilter: %v", err) } if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, date_start, date_end, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, %s, 'pilot', 'main', ?, ?, ?, ?, ?, ?)`, asBlob([]byte("crew-softdel-001")), asBlob(rosterID), asBlob(userID)), time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), "2000-01-01 08:00:00", "2000-01-01 22:00:00", now, now).Error; err != nil { t.Fatalf("seed crew for softDeleteCrewByFilter: %v", err) } filterDate := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC) err := softDeleteCrewByFilter(db, [][]byte{rosterID}, dutyroster.DutyRosterCrew{ RoleCode: "pilot", CrewType: "main", UserID: userID, DateStart: &filterDate, DateEnd: &filterDate, }, now, uuidv7.MustBytes()) if err == nil { t.Fatalf("expected sqlite dialect error for UPDATE alias query") } } func TestDutyRosterRepository_NotFoundAndRowsAffectedBranches(t *testing.T) { db := openDutyRosterRepoCoverageDB(t) repo := NewDutyRosterRepository(db) ctx := context.Background() missingID := []byte("missing-roster-01") baseID := []byte("base-not-found-1") dutyDate := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) if _, err := repo.GetRosterByID(ctx, missingID); err == nil { t.Fatalf("expected GetRosterByID not found error") } if _, err := repo.LockRosterByID(ctx, missingID); err == nil { t.Fatalf("expected LockRosterByID not found error") } if err := repo.UpdateHeader(ctx, &dutyroster.DutyRoster{ ID: missingID, BaseID: baseID, DutyDate: dutyDate, UpdatedBy: uuidv7.MustBytes(), }); err == nil { t.Fatalf("expected UpdateHeader not found error") } if err := repo.SoftDeleteHeader(ctx, missingID, uuidv7.MustBytes()); err == nil { t.Fatalf("expected SoftDeleteHeader not found error") } if _, err := repo.GetBaseDefaultShift(ctx, baseID, "base"); err == nil { t.Fatalf("expected GetBaseDefaultShift not found error") } // with non-empty baseID filter branch on list bases if rows, err := repo.ListHEMSBases(ctx, baseID); err != nil || len(rows) != 0 { t.Fatalf("expected empty ListHEMSBases filtered result, got len=%d err=%v", len(rows), err) } if rows, err := repo.ListBasesByType(ctx, baseID, "base"); err != nil || len(rows) != 0 { t.Fatalf("expected empty ListBasesByType filtered result, got len=%d err=%v", len(rows), err) } if err := db.Exec("DROP TABLE duty_rosters").Error; err != nil { t.Fatalf("drop duty_rosters: %v", err) } if _, err := repo.FindRosterIDByBaseDate(ctx, baseID, dutyDate); err == nil { t.Fatalf("expected query error after dropping roster table") } } func TestDutyRosterRepository_LowLevelHelperBranches(t *testing.T) { db := openDutyRosterRepoCoverageDB(t) now := time.Now().UTC() asBlob := func(b []byte) string { return fmt.Sprintf("X'%x'", b) } baseID := []byte("hems-base-helper01") _, hemsCategoryID := dutyRosterTestCategoryIDs() rosterID := []byte("roster-helper-001") userID := []byte("user-helper-0001") crewIDUser := []byte("crew-helper-user1") crewIDGuest := []byte("crew-helper-guest") dutyDate := time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC) if err := db.Exec(fmt.Sprintf(`INSERT INTO bases(id, base_category_id, base, base_abbreviation, sortkey, is_active, default_shift_time) VALUES(%s, %s, ?, ?, 1, 1, ?)`, asBlob(baseID), asBlob(hemsCategoryID)), "HEMS H", "HH", "06:00-21:00").Error; err != nil { t.Fatalf("seed base: %v", err) } if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_rosters(id, base_id, duty_date, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, ?, ?, ?, ?, ?)`, asBlob(rosterID), asBlob(baseID)), dutyDate, "2000-01-01 06:00:00", "2000-01-01 21:00:00", now, now).Error; err != nil { t.Fatalf("seed roster: %v", err) } if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, date_start, date_end, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, %s, 'pilot', 'main', '', '', '', ?, ?, ?, ?, ?, ?)`, asBlob(crewIDUser), asBlob(rosterID), asBlob(userID)), dutyDate, dutyDate, "2000-01-01 08:00:00", "2000-01-01 22:00:00", now, now).Error; err != nil { t.Fatalf("seed user crew: %v", err) } if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, NULL, 'other_person', 'main', 'Guest', '+62000', 'g@example.com', ?, ?, ?, ?)`, asBlob(crewIDGuest), asBlob(rosterID)), "2000-01-01 09:00:00", "2000-01-01 18:00:00", now, now).Error; err != nil { t.Fatalf("seed guest crew: %v", err) } // softReplaceCrewByUser branches if err := softReplaceCrewByUser(db, nil, "pilot", "main", userID, uuidv7.MustBytes()); err != nil { t.Fatalf("softReplaceCrewByUser empty roster should be nil: %v", err) } if err := softReplaceCrewByUser(db, rosterID, "pilot", "main", userID, uuidv7.MustBytes()); err != nil { t.Fatalf("softReplaceCrewByUser update branch failed: %v", err) } // crewExists branches (user/date nil and set) existsGuest, err := crewExists(db, dutyroster.DutyRosterCrew{ RosterID: rosterID, RoleCode: "other_person", CrewType: "main", NameLabel: "Guest", MobilePhone: "+62000", Email: "g@example.com", ShiftStart: "2000-01-01 09:00:00", ShiftEnd: "2000-01-01 18:00:00", }) if err != nil || !existsGuest { t.Fatalf("expected guest crew exists, got exists=%v err=%v", existsGuest, err) } start := dutyDate end := dutyDate existsUser, err := crewExists(db, dutyroster.DutyRosterCrew{ RosterID: rosterID, UserID: userID, RoleCode: "pilot", CrewType: "main", DateStart: &start, DateEnd: &end, }) if err != nil { t.Fatalf("crewExists user branch err: %v", err) } if existsUser { t.Fatalf("expected false after softReplaceCrewByUser (row should be deleted)") } // softDeleteCrewForDate branches if err := softDeleteCrewForDate(db, []byte("base-not-exist"), dutyDate, dutyroster.DutyRosterCrew{ RoleCode: "pilot", CrewType: "main", }, now, uuidv7.MustBytes()); err != nil { t.Fatalf("softDeleteCrewForDate no roster branch should be nil: %v", err) } if err := softDeleteCrewForDate(db, baseID, dutyDate, dutyroster.DutyRosterCrew{ RoleCode: "other_person", CrewType: "main", NameLabel: "Guest", MobilePhone: "+62000", Email: "g@example.com", }, now, uuidv7.MustBytes()); err != nil { t.Fatalf("softDeleteCrewForDate guest branch failed: %v", err) } // matchCrewByFilter name_label/date/shift filter branch if err := db.Exec(fmt.Sprintf(`INSERT INTO duty_roster_crews(id, roster_id, user_id, role_code, crew_type, name_label, mobile_phone, email, shift_start, shift_end, created_at, updated_at) VALUES(%s, %s, NULL, 'other_person', 'main', 'Guest', '+62000', 'g@example.com', ?, ?, ?, ?)`, asBlob([]byte("crew-helper-guest2")), asBlob(rosterID)), "2000-01-01 09:00:00", "2000-01-01 18:00:00", now, now).Error; err != nil { t.Fatalf("seed guest2 crew: %v", err) } rows, err := matchCrewByFilter(db, [][]byte{rosterID}, dutyroster.DutyRosterCrew{ RoleCode: "other_person", CrewType: "main", NameLabel: "Guest", MobilePhone: "+62000", Email: "g@example.com", }) if err != nil || len(rows) == 0 { t.Fatalf("expected matchCrewByFilter by name_label branch, got len=%d err=%v", len(rows), err) } }