Files
fm_be/internal/service/mission_service_test.go
2026-07-16 22:16:45 +07:00

582 lines
18 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"time"
afterflightinspection "wucher/internal/domain/after_flight_inspection"
basedomain "wucher/internal/domain/base"
flightdomain "wucher/internal/domain/flight"
flightdata "wucher/internal/domain/flight_data"
"wucher/internal/domain/mission"
reserveac "wucher/internal/domain/reserve_ac"
takeoverdomain "wucher/internal/domain/takeover"
"wucher/internal/shared/pkg/util"
)
type missionRepoMock struct {
createFn func(context.Context, *mission.Mission) error
findCategoryByCodeFn func(context.Context, string) (*mission.MissionCategory, error)
updateFlightDataIDByIDFn func(context.Context, []byte, []byte, []byte) error
deleteByIDFn func(context.Context, []byte, []byte) error
deleteByFlightIDFn func(ctx context.Context, flightID []byte, deletedBy []byte) error
maxCodeSeqFn func() (int, error)
}
func (m *missionRepoMock) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
return fn(ctx)
}
func (m *missionRepoMock) Create(ctx context.Context, row *mission.Mission) error {
if m.createFn != nil {
return m.createFn(ctx, row)
}
return nil
}
func (m *missionRepoMock) CreateCategory(context.Context, *mission.MissionCategory) error { return nil }
func (m *missionRepoMock) CreatePlaceholder(context.Context, *flightdata.FlightData) error {
return nil
}
func (m *missionRepoMock) UpdateFlightDataIDByID(ctx context.Context, missionID, flightDataID, updatedBy []byte) error {
if m.updateFlightDataIDByIDFn != nil {
return m.updateFlightDataIDByIDFn(ctx, missionID, flightDataID, updatedBy)
}
return nil
}
func (m *missionRepoMock) UpdateByID(context.Context, []byte, string, []byte, []byte, string, []byte) error {
return nil
}
func (m *missionRepoMock) AttachFile(context.Context, []byte, []byte) error {
return nil
}
func (m *missionRepoMock) DetachFile(context.Context, []byte, []byte) error {
return nil
}
func (m *missionRepoMock) DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error {
if m.deleteByIDFn != nil {
return m.deleteByIDFn(ctx, missionID, deletedBy)
}
return nil
}
func (m *missionRepoMock) DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error {
if m.deleteByFlightIDFn != nil {
return m.deleteByFlightIDFn(ctx, flightID, deletedBy)
}
return nil
}
func (m *missionRepoMock) ListByFlightID(context.Context, []byte) ([]mission.Mission, error) {
return nil, nil
}
func (m *missionRepoMock) ListByFlightIDs(context.Context, [][]byte) ([]mission.Mission, error) {
return nil, nil
}
func (m *missionRepoMock) GetByID(context.Context, []byte) (*mission.Mission, error) {
return nil, nil
}
func (m *missionRepoMock) GetByFlightID(context.Context, []byte) (*mission.Mission, error) {
return nil, nil
}
func (m *missionRepoMock) List(context.Context, string, string, []byte, string, int, int) ([]mission.Mission, int64, error) {
return nil, 0, nil
}
func (m *missionRepoMock) ListDatatable(context.Context, mission.ListFilter, int, int) ([]mission.Mission, int64, int64, error) {
return nil, 0, 0, nil
}
func (m *missionRepoMock) ListCategoriesWithSubCategories(context.Context) ([]mission.MissionCategory, error) {
return nil, nil
}
func (m *missionRepoMock) FindCategoryByCode(ctx context.Context, code string) (*mission.MissionCategory, error) {
if m.findCategoryByCodeFn != nil {
return m.findCategoryByCodeFn(ctx, code)
}
return nil, nil
}
func (m *missionRepoMock) SubCategoryBelongsToCategory(context.Context, []byte, []byte) (bool, error) {
return false, nil
}
func (m *missionRepoMock) MaxCodeSeqByPrefix(context.Context, string) (int, error) {
if m.maxCodeSeqFn != nil {
return m.maxCodeSeqFn()
}
return 0, nil
}
type flightDataSvcMockForMission struct {
createPlaceholderFn func(context.Context, *flightdata.FlightData) error
}
type afterFlightInspectionSvcMockForMission struct {
getByFlightInspectionIDFn func(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error)
}
type flightSvcMockForMission struct {
getByIDFn func(context.Context, []byte) (*flightdomain.Flight, error)
}
func (m *flightSvcMockForMission) Create(context.Context, *flightdomain.Flight) error { return nil }
func (m *flightSvcMockForMission) Update(context.Context, *flightdomain.Flight) error { return nil }
func (m *flightSvcMockForMission) Delete(context.Context, []byte, []byte) error { return nil }
func (m *flightSvcMockForMission) GetByID(ctx context.Context, id []byte) (*flightdomain.Flight, error) {
if m.getByIDFn != nil {
return m.getByIDFn(ctx, id)
}
return nil, nil
}
func (m *flightSvcMockForMission) ListByUserID(context.Context, []byte, int, int) ([]flightdomain.Flight, int64, error) {
return nil, 0, nil
}
func (m *flightSvcMockForMission) ListByCreatedBy(context.Context, []byte, string, string, string, int, int) ([]flightdomain.Flight, int64, error) {
return nil, 0, nil
}
func (m *flightSvcMockForMission) GetByReserveAcID(context.Context, []byte) (*flightdomain.Flight, error) {
return nil, nil
}
func (m *flightSvcMockForMission) ListByReserveAcID(context.Context, []byte) ([]flightdomain.Flight, error) {
return nil, nil
}
func (m *flightSvcMockForMission) GetByTakeoverAcID(context.Context, []byte) (*flightdomain.Flight, error) {
return nil, nil
}
func (m *flightSvcMockForMission) ListByTakeoverAcIDs(context.Context, [][]byte) ([]flightdomain.Flight, error) {
return nil, nil
}
func (m *flightSvcMockForMission) GetByDutyRosterID(context.Context, []byte) (*flightdomain.Flight, error) {
return nil, nil
}
func (m *flightSvcMockForMission) ListByDutyRosterIDs(context.Context, [][]byte) ([]flightdomain.Flight, error) {
return nil, nil
}
func (m *flightSvcMockForMission) List(context.Context, string, string, string, int, int) ([]flightdomain.Flight, int64, error) {
return nil, 0, nil
}
func (m *flightDataSvcMockForMission) Create(context.Context, *flightdata.FlightData) error {
return nil
}
func (m *flightDataSvcMockForMission) CreatePlaceholder(ctx context.Context, row *flightdata.FlightData) error {
if m.createPlaceholderFn != nil {
return m.createPlaceholderFn(ctx, row)
}
return nil
}
func (m *flightDataSvcMockForMission) Update(context.Context, *flightdata.FlightData) error {
return nil
}
func (m *flightDataSvcMockForMission) Delete(context.Context, []byte, []byte) error {
return nil
}
func (m *flightDataSvcMockForMission) GetByID(context.Context, []byte) (*flightdata.FlightData, error) {
return nil, nil
}
func (m *flightDataSvcMockForMission) GetByFlightID(context.Context, []byte) (*flightdata.FlightData, error) {
return nil, nil
}
func (m *flightDataSvcMockForMission) GetByMissionID(context.Context, []byte) (*flightdata.FlightData, error) {
return nil, nil
}
func (m *flightDataSvcMockForMission) ListByMissionID(context.Context, []byte) ([]flightdata.FlightData, error) {
return nil, nil
}
func (m *flightDataSvcMockForMission) List(context.Context, string, []byte, []byte, int, int) ([]flightdata.FlightData, int64, error) {
return nil, 0, nil
}
func (m *flightDataSvcMockForMission) GetSPODetailsByFlightDataID(context.Context, []byte) (*flightdata.SPODetails, error) {
return nil, nil
}
func (m *flightDataSvcMockForMission) UpsertSPODetails(context.Context, []byte, *flightdata.SPOUpsertData) error {
return nil
}
func (m *afterFlightInspectionSvcMockForMission) Upsert(context.Context, []byte, *afterflightinspection.UpsertRequest) (*afterflightinspection.AfterFlightInspection, error) {
return nil, nil
}
func (m *afterFlightInspectionSvcMockForMission) GetByFlightInspectionID(ctx context.Context, id []byte) (*afterflightinspection.AfterFlightInspection, error) {
if m.getByFlightInspectionIDFn != nil {
return m.getByFlightInspectionIDFn(ctx, id)
}
return nil, nil
}
func (m *afterFlightInspectionSvcMockForMission) DeleteByFlightInspectionID(context.Context, []byte) error {
return nil
}
func TestMissionServiceCreateDoesNotAutoCreateFlightData(t *testing.T) {
var createdPlaceholder *flightdata.FlightData
var updatedFlightDataID bool
flightID := []byte("flight-id-123456")
date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
baseRow := &basedomain.Base{
ID: []byte("base-id-12345678"),
BaseName: "Zurich",
Latitude: 47.3769,
Longitude: 8.5417,
UTC: "Europe/Zurich",
OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{{
DateStart: &date,
DateEnd: &date,
StartTimeType: basedomain.ShiftTimeTypeFixed,
EndTimeType: basedomain.ShiftTimeTypeFixed,
ShiftStart: "09:00:00",
ShiftEnd: "17:00:00",
}},
}
svc := NewMissionService(
&missionRepoMock{
findCategoryByCodeFn: func(context.Context, string) (*mission.MissionCategory, error) {
return &mission.MissionCategory{ID: []byte("category-id-1234"), CodeType: "SPO", TypeName: "SPO"}, nil
},
updateFlightDataIDByIDFn: func(context.Context, []byte, []byte, []byte) error {
updatedFlightDataID = true
return nil
},
maxCodeSeqFn: func() (int, error) { return 4, nil },
createFn: func(_ context.Context, row *mission.Mission) error {
if row.StartTime == "" || row.EndTime == "" {
t.Fatalf("expected resolved mission times on create, got start=%q end=%q", row.StartTime, row.EndTime)
}
if !strings.HasPrefix(row.Code, "SPO-") || !strings.HasSuffix(row.Code, "-5") {
t.Fatalf("expected generated code SPO-<yy>-5, got %q", row.Code)
}
return nil
},
},
&flightSvcMockForMission{
getByIDFn: func(context.Context, []byte) (*flightdomain.Flight, error) {
return &flightdomain.Flight{
ID: flightID,
Date: date,
Takeover: &takeoverdomain.TakeoverAc{
Base: baseRow,
},
}, nil
},
},
&flightDataSvcMockForMission{
createPlaceholderFn: func(_ context.Context, row *flightdata.FlightData) error {
row.ID = []byte("flight-data-id-123")
createdPlaceholder = row
return nil
},
},
)
row := &mission.Mission{
FlightID: flightID,
Type: "spo",
CreatedBy: []byte("creator-id-12345"),
UpdatedBy: []byte("creator-id-12345"),
}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("Create returned error: %v", err)
}
// Mission create must NOT auto-create any flight data anymore.
if createdPlaceholder != nil {
t.Fatal("expected no placeholder flight data to be created on mission create")
}
if updatedFlightDataID {
t.Fatal("expected mission flight_data_id not to be touched")
}
if len(row.FlightDataID) != 0 {
t.Fatalf("expected mission flight_data_id to stay empty, got %x", row.FlightDataID)
}
}
func TestMissionServiceCreateBlockedWhenAfterFlightInspectionExists(t *testing.T) {
flightID := []byte("flight-id-123456")
inspectionID := []byte("inspection-id-12")
date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
baseRow := &basedomain.Base{
ID: []byte("base-id-12345678"),
BaseName: "Zurich",
Latitude: 47.3769,
Longitude: 8.5417,
UTC: "Europe/Zurich",
OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{{
DateStart: &date,
DateEnd: &date,
StartTimeType: basedomain.ShiftTimeTypeFixed,
EndTimeType: basedomain.ShiftTimeTypeFixed,
ShiftStart: "09:00:00",
ShiftEnd: "17:00:00",
}},
}
repo := &missionRepoMock{
findCategoryByCodeFn: func(context.Context, string) (*mission.MissionCategory, error) {
return &mission.MissionCategory{ID: []byte("category-id-1234"), CodeType: "SPO", TypeName: "SPO"}, nil
},
createFn: func(context.Context, *mission.Mission) error {
t.Fatal("mission create should not be called when after flight inspection exists")
return nil
},
}
svc := NewMissionService(
repo,
&flightSvcMockForMission{
getByIDFn: func(context.Context, []byte) (*flightdomain.Flight, error) {
return &flightdomain.Flight{
ID: flightID,
Date: date,
Takeover: &takeoverdomain.TakeoverAc{
Base: baseRow,
ReserveAc: &reserveac.ReserveAc{InspectionID: inspectionID},
},
}, nil
},
},
&afterFlightInspectionSvcMockForMission{
getByFlightInspectionIDFn: func(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error) {
return &afterflightinspection.AfterFlightInspection{ID: []byte("after-flight-123")}, nil
},
},
)
err := svc.Create(context.Background(), &mission.Mission{
FlightID: flightID,
Type: "SPO",
CreatedBy: []byte("creator-12345678"),
UpdatedBy: []byte("creator-12345678"),
})
if !errors.Is(err, mission.ErrLockedAfterFlightInspection) {
t.Fatalf("expected after-flight inspection lock error, got %v", err)
}
}
func TestMissionServiceCreateResolvesMissionTimes(t *testing.T) {
flightID := []byte("flight-id-123456")
date := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
startType string
endType string
startClock string
endClock string
useDefaultShift bool
defaultStart string
defaultEnd string
wantStart string
wantEnd string
}{
{
name: "fixed",
startType: basedomain.ShiftTimeTypeFixed,
endType: basedomain.ShiftTimeTypeFixed,
startClock: "06:00:00",
endClock: "21:00:00",
wantStart: "06:00",
wantEnd: "21:00",
},
{
name: "bmct and ecet",
startType: basedomain.ShiftTimeTypeBMCT,
endType: basedomain.ShiftTimeTypeECET,
wantStart: "04:49",
wantEnd: "22:05",
},
{
name: "mixed fixed start",
startType: basedomain.ShiftTimeTypeFixed,
endType: basedomain.ShiftTimeTypeECET,
startClock: "23:59:00",
wantStart: "23:59",
wantEnd: "22:05",
},
{
name: "mixed fixed end",
startType: basedomain.ShiftTimeTypeBMCT,
endType: basedomain.ShiftTimeTypeFixed,
endClock: "23:59:00",
wantStart: "04:49",
wantEnd: "23:59",
},
{
name: "fallback to default shift when operational schedule missing",
useDefaultShift: true,
wantStart: "06:00",
wantEnd: "21:00",
},
{
name: "fallback accepts datetime default shifts",
useDefaultShift: true,
defaultStart: "2000-01-01 06:00:00",
defaultEnd: "2000-01-01 21:00:00",
wantStart: "06:00",
wantEnd: "21:00",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
baseRow := &basedomain.Base{
ID: []byte("base-id-12345678"),
BaseName: "Zurich",
Latitude: 47.3769,
Longitude: 8.5417,
UTC: "Europe/Zurich",
DefaultShiftStart: "06:00:00",
DefaultShiftEnd: "21:00:00",
}
if tc.defaultStart != "" {
baseRow.DefaultShiftStart = tc.defaultStart
}
if tc.defaultEnd != "" {
baseRow.DefaultShiftEnd = tc.defaultEnd
}
if !tc.useDefaultShift {
baseRow.OperationalShiftTimes = []basedomain.BaseOperationalShiftTime{{
DateStart: &date,
DateEnd: &date,
StartTimeType: tc.startType,
EndTimeType: tc.endType,
ShiftStart: tc.startClock,
ShiftEnd: tc.endClock,
}}
}
var gotStart, gotEnd string
svc := NewMissionService(
&missionRepoMock{
findCategoryByCodeFn: func(context.Context, string) (*mission.MissionCategory, error) {
return &mission.MissionCategory{ID: []byte("category-id-1234"), CodeType: "SPO", TypeName: "SPO"}, nil
},
createFn: func(_ context.Context, row *mission.Mission) error {
gotStart = row.StartTime
gotEnd = row.EndTime
return nil
},
},
&flightSvcMockForMission{
getByIDFn: func(context.Context, []byte) (*flightdomain.Flight, error) {
return &flightdomain.Flight{
ID: flightID,
Date: date,
Takeover: &takeoverdomain.TakeoverAc{
Base: baseRow,
},
}, nil
},
},
)
row := &mission.Mission{FlightID: flightID, Type: "SPO"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("Create returned error: %v", err)
}
if gotStart != tc.wantStart || gotEnd != tc.wantEnd {
t.Fatalf("unexpected resolved times: got %s/%s want %s/%s", gotStart, gotEnd, tc.wantStart, tc.wantEnd)
}
})
}
}
func TestMissionServiceDeleteByFlightIDRequiresFlightID(t *testing.T) {
svc := NewMissionService(&missionRepoMock{})
err := svc.DeleteByFlightID(context.Background(), nil, nil)
if !errors.Is(err, mission.ErrFlightIDRequired) {
t.Fatalf("expected ErrFlightIDRequired, got %v", err)
}
}
func TestMissionServiceDeleteByFlightIDDelegatesToRepo(t *testing.T) {
wantFlightID := []byte("1234567890123456")
wantDeletedBy := []byte("6543210987654321")
called := false
svc := NewMissionService(&missionRepoMock{
deleteByFlightIDFn: func(_ context.Context, flightID []byte, deletedBy []byte) error {
called = true
if string(flightID) != string(wantFlightID) {
t.Fatalf("unexpected flightID: %x", flightID)
}
if string(deletedBy) != string(wantDeletedBy) {
t.Fatalf("unexpected deletedBy: %x", deletedBy)
}
return nil
},
})
if err := svc.DeleteByFlightID(context.Background(), wantFlightID, wantDeletedBy); err != nil {
t.Fatalf("DeleteByFlightID returned error: %v", err)
}
if !called {
t.Fatal("expected repository DeleteByFlightID to be called")
}
}
func TestNormalizeMissionClockValueAcceptsDatetime(t *testing.T) {
got := util.NormalizeClockValue("2000-01-01 08:00:00")
if got != "08:00" {
t.Fatalf("expected 08:00, got %q", got)
}
}
func TestMissionServiceDeleteByIDRequiresMissionID(t *testing.T) {
svc := NewMissionService(&missionRepoMock{})
err := svc.DeleteByID(context.Background(), nil, nil)
if !errors.Is(err, mission.ErrMissionIDRequired) {
t.Fatalf("expected ErrMissionIDRequired, got %v", err)
}
}
func TestMissionServiceDeleteByIDDelegatesToRepo(t *testing.T) {
wantMissionID := []byte("1234567890123456")
wantDeletedBy := []byte("6543210987654321")
called := false
svc := NewMissionService(&missionRepoMock{
deleteByIDFn: func(_ context.Context, missionID []byte, deletedBy []byte) error {
called = true
if string(missionID) != string(wantMissionID) {
t.Fatalf("unexpected missionID: %x", missionID)
}
if string(deletedBy) != string(wantDeletedBy) {
t.Fatalf("unexpected deletedBy: %x", deletedBy)
}
return nil
},
})
if err := svc.DeleteByID(context.Background(), wantMissionID, wantDeletedBy); err != nil {
t.Fatalf("DeleteByID returned error: %v", err)
}
if !called {
t.Fatal("expected repository DeleteByID to be called")
}
}