750 lines
26 KiB
Go
750 lines
26 KiB
Go
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)
|
|
}
|
|
}
|