init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
package service
import (
"context"
"time"
actionsignoff "wucher/internal/domain/action_signoff"
)
type ActionSignoffService struct{ repo actionsignoff.Repository }
func NewActionSignoffService(repo actionsignoff.Repository) *ActionSignoffService {
return &ActionSignoffService{repo: repo}
}
func (s *ActionSignoffService) GetByFlight(ctx context.Context, flightID []byte) (*actionsignoff.ActionSignoff, error) {
return s.repo.GetByFlight(ctx, flightID)
}
func (s *ActionSignoffService) GetByComplaint(ctx context.Context, complaintID []byte) (*actionsignoff.ActionSignoff, error) {
return s.repo.GetByComplaint(ctx, complaintID)
}
func (s *ActionSignoffService) GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*actionsignoff.ActionSignoff, error) {
return s.repo.GetByComplaintIDs(ctx, complaintIDs)
}
// UnsignByComplaint clears the signature on the complaint-linked sign-off. No-op when
// there is no sign-off for the complaint or it is already unsigned.
func (s *ActionSignoffService) UnsignByComplaint(ctx context.Context, complaintID []byte) (*actionsignoff.ActionSignoff, error) {
row, err := s.repo.GetByComplaint(ctx, complaintID)
if err != nil {
return nil, err
}
if row == nil {
return nil, nil
}
if row.SignedAt == nil {
return row, nil
}
row.SignedAt = nil
row.SignedBy = nil
if err := s.repo.Update(ctx, row); err != nil {
return nil, err
}
return row, nil
}
// SignForComplaint records (or re-stamps) the action sign-off linked to a specific
// complaint — i.e. "this complaint's corrective action was signed off". Upsert by
// complaint: creates the row on first sign, otherwise re-stamps signer/time.
func (s *ActionSignoffService) SignForComplaint(ctx context.Context, complaintID, flightID, helicopterID []byte, signer []byte) (*actionsignoff.ActionSignoff, error) {
row, err := s.repo.GetByComplaint(ctx, complaintID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if row == nil {
row = &actionsignoff.ActionSignoff{
HelicopterID: append([]byte(nil), helicopterID...),
FlightID: append([]byte(nil), flightID...),
ComplaintID: append([]byte(nil), complaintID...),
SignedAt: &now,
SignedBy: append([]byte(nil), signer...),
}
if err := s.repo.Create(ctx, row); err != nil {
return nil, err
}
return row, nil
}
row.FlightID = append([]byte(nil), flightID...)
row.SignedAt = &now
row.SignedBy = append([]byte(nil), signer...)
if err := s.repo.Update(ctx, row); err != nil {
return nil, err
}
return row, nil
}
// Sign records (or re-stamps) the helicopter's action sign-off as signed by the
func (s *ActionSignoffService) Sign(ctx context.Context, flightID, helicopterID []byte, signer []byte) (*actionsignoff.ActionSignoff, error) {
row, err := s.repo.GetByFlight(ctx, flightID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if row == nil {
row = &actionsignoff.ActionSignoff{
HelicopterID: append([]byte(nil), helicopterID...),
FlightID: append([]byte(nil), flightID...),
SignedAt: &now,
SignedBy: append([]byte(nil), signer...),
}
if err := s.repo.Create(ctx, row); err != nil {
return nil, err
}
return row, nil
}
row.SignedAt = &now
row.SignedBy = append([]byte(nil), signer...)
if err := s.repo.Update(ctx, row); err != nil {
return nil, err
}
return row, nil
}
// Unsign clears the signature (check → uncheck). Returns ErrNotFound when there
// is nothing to unsign.
func (s *ActionSignoffService) Unsign(ctx context.Context, flightID []byte, _ []byte) (*actionsignoff.ActionSignoff, error) {
row, err := s.repo.GetByFlight(ctx, flightID)
if err != nil {
return nil, err
}
if row == nil {
return nil, actionsignoff.ErrNotFound
}
row.SignedAt = nil
row.SignedBy = nil
if err := s.repo.Update(ctx, row); err != nil {
return nil, err
}
return row, nil
}

View File

@@ -0,0 +1,168 @@
package service
import (
"context"
"fmt"
"strings"
afterflightinspection "wucher/internal/domain/after_flight_inspection"
"wucher/internal/shared/pkg/appctx"
)
type AfterFlightInspectionService struct {
repo afterflightinspection.Repository
}
func NewAfterFlightInspectionService(repo afterflightinspection.Repository) *AfterFlightInspectionService {
return &AfterFlightInspectionService{repo: repo}
}
func (s *AfterFlightInspectionService) Upsert(ctx context.Context, flightInspectionID []byte, req *afterflightinspection.UpsertRequest) (*afterflightinspection.AfterFlightInspection, error) {
if err := s.ensureFlightInspection(ctx, flightInspectionID); err != nil {
return nil, err
}
caps, err := s.repo.GetHelicopterCapabilities(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to get helicopter capabilities: %w", err)
}
if err := s.validateRequest(req, caps); err != nil {
return nil, err
}
row := &afterflightinspection.AfterFlightInspection{
FlightInspectionID: flightInspectionID,
OilEngineNR1Checked: req.OilEngineNR1Checked,
OilEngineNR2Checked: req.OilEngineNR2Checked,
HydraulicLHChecked: req.HydraulicLHChecked,
HydraulicRHChecked: req.HydraulicRHChecked,
OilTransmissionMGBChecked: req.OilTransmissionMGBChecked,
OilTransmissionIGBChecked: req.OilTransmissionIGBChecked,
OilTransmissionTGBChecked: req.OilTransmissionTGBChecked,
Note: req.Note,
}
existing, err := s.repo.GetByFlightInspectionID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to load after flight inspection: %w", err)
}
userID := appctx.GetUserID(ctx)
if existing != nil {
row.ID = existing.ID
row.CreatedAt = existing.CreatedAt
row.CreatedBy = existing.CreatedBy
if userID != nil {
row.UpdatedBy = userID
} else {
row.UpdatedBy = existing.UpdatedBy
}
} else if userID != nil {
row.CreatedBy = userID
row.UpdatedBy = userID
}
if err := s.repo.Upsert(ctx, row); err != nil {
return nil, fmt.Errorf("failed to upsert after flight inspection: %w", err)
}
return row, nil
}
func (s *AfterFlightInspectionService) GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*afterflightinspection.AfterFlightInspection, error) {
if err := s.ensureFlightInspection(ctx, flightInspectionID); err != nil {
return nil, err
}
row, err := s.repo.GetByFlightInspectionID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to get after flight inspection: %w", err)
}
return row, nil
}
func (s *AfterFlightInspectionService) DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error {
if err := s.ensureFlightInspection(ctx, flightInspectionID); err != nil {
return err
}
if err := s.repo.DeleteByFlightInspectionID(ctx, flightInspectionID); err != nil {
return fmt.Errorf("failed to delete after flight inspection: %w", err)
}
return nil
}
// ensureFlightInspection validates the identifier and confirms the referenced
// flight inspection exists. It returns domain sentinel errors so the transport
// layer can map them to apperrorsx definitions.
func (s *AfterFlightInspectionService) ensureFlightInspection(ctx context.Context, flightInspectionID []byte) error {
if len(flightInspectionID) != 16 {
return afterflightinspection.ErrInvalidFlightInspectionID
}
exists, err := s.repo.FlightInspectionExists(ctx, flightInspectionID)
if err != nil {
return fmt.Errorf("failed to check flight inspection: %w", err)
}
if !exists {
return afterflightinspection.ErrFlightInspectionNotFound
}
return nil
}
func (s *AfterFlightInspectionService) validateRequest(req *afterflightinspection.UpsertRequest, caps *afterflightinspection.HelicopterCapabilities) error {
if caps == nil {
return afterflightinspection.ErrCapabilitiesUnavailable
}
if caps.NR1 && !hasNonEmptyString(req.OilEngineNR1Checked) {
return afterflightinspection.ErrNR1Required
}
if hasNonEmptyString(req.OilEngineNR1Checked) && !caps.NR1 {
return afterflightinspection.ErrNR1NotAvailable
}
if caps.NR2 && !hasNonEmptyString(req.OilEngineNR2Checked) {
return afterflightinspection.ErrNR2Required
}
if hasNonEmptyString(req.OilEngineNR2Checked) && !caps.NR2 {
return afterflightinspection.ErrNR2NotAvailable
}
if caps.LH && !hasNonEmptyString(req.HydraulicLHChecked) {
return afterflightinspection.ErrLHRequired
}
if hasNonEmptyString(req.HydraulicLHChecked) && !caps.LH {
return afterflightinspection.ErrLHNotAvailable
}
if caps.RH && !hasNonEmptyString(req.HydraulicRHChecked) {
return afterflightinspection.ErrRHRequired
}
if hasNonEmptyString(req.HydraulicRHChecked) && !caps.RH {
return afterflightinspection.ErrRHNotAvailable
}
if caps.MGB && !hasNonEmptyString(req.OilTransmissionMGBChecked) {
return afterflightinspection.ErrMGBRequired
}
if hasNonEmptyString(req.OilTransmissionMGBChecked) && !caps.MGB {
return afterflightinspection.ErrMGBNotAvailable
}
if caps.IGB && !hasNonEmptyString(req.OilTransmissionIGBChecked) {
return afterflightinspection.ErrIGBRequired
}
if hasNonEmptyString(req.OilTransmissionIGBChecked) && !caps.IGB {
return afterflightinspection.ErrIGBNotAvailable
}
if caps.TGB && !hasNonEmptyString(req.OilTransmissionTGBChecked) {
return afterflightinspection.ErrTGBRequired
}
if hasNonEmptyString(req.OilTransmissionTGBChecked) && !caps.TGB {
return afterflightinspection.ErrTGBNotAvailable
}
return nil
}
func hasNonEmptyString(v *string) bool {
return v != nil && strings.TrimSpace(*v) != ""
}

View File

@@ -0,0 +1,168 @@
package service
import (
"context"
"testing"
"time"
afterflightinspection "wucher/internal/domain/after_flight_inspection"
)
type afterFlightInspectionRepoStub struct {
exists bool
caps *afterflightinspection.HelicopterCapabilities
existing *afterflightinspection.AfterFlightInspection
upserted *afterflightinspection.AfterFlightInspection
upsertErr error
existsErr error
capErr error
flightChecked int
}
func (r *afterFlightInspectionRepoStub) Upsert(_ context.Context, row *afterflightinspection.AfterFlightInspection) error {
r.upserted = row
return r.upsertErr
}
func (r *afterFlightInspectionRepoStub) GetByFlightInspectionID(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error) {
return r.existing, nil
}
func (r *afterFlightInspectionRepoStub) DeleteByFlightInspectionID(context.Context, []byte) error {
return nil
}
func (r *afterFlightInspectionRepoStub) FlightInspectionExists(context.Context, []byte) (bool, error) {
if r.existsErr != nil {
return false, r.existsErr
}
return r.exists, nil
}
func (r *afterFlightInspectionRepoStub) GetHelicopterCapabilities(context.Context, []byte) (*afterflightinspection.HelicopterCapabilities, error) {
if r.capErr != nil {
return nil, r.capErr
}
r.flightChecked++
return r.caps, nil
}
func TestAfterFlightInspectionServiceValidateRequestRequiresCapabilityChecks(t *testing.T) {
svc := NewAfterFlightInspectionService(&afterFlightInspectionRepoStub{})
nr1 := "checked"
nr2 := "checked"
lh := "checked"
rh := "checked"
mgb := "checked"
igb := "checked"
tgb := "checked"
req := &afterflightinspection.UpsertRequest{
OilEngineNR1Checked: &nr1,
OilEngineNR2Checked: &nr2,
HydraulicLHChecked: &lh,
HydraulicRHChecked: &rh,
OilTransmissionMGBChecked: &mgb,
OilTransmissionIGBChecked: &igb,
OilTransmissionTGBChecked: &tgb,
}
caps := &afterflightinspection.HelicopterCapabilities{
NR1: true, NR2: true, LH: true, RH: true, MGB: true, IGB: true, TGB: true,
}
if err := svc.validateRequest(req, caps); err != nil {
t.Fatalf("expected request to be valid, got %v", err)
}
req.OilEngineNR2Checked = nil
if err := svc.validateRequest(req, caps); err == nil {
t.Fatal("expected missing required nr2 check to be rejected")
}
}
func TestAfterFlightInspectionServiceUpsertUpdatesExistingRow(t *testing.T) {
existingID := make([]byte, 16)
for i := range existingID {
existingID[i] = byte(i + 1)
}
createdBy := []byte("creator123456789")
createdAt := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC)
repo := &afterFlightInspectionRepoStub{
exists: true,
caps: &afterflightinspection.HelicopterCapabilities{NR1: true},
existing: &afterflightinspection.AfterFlightInspection{
ID: existingID,
CreatedAt: createdAt,
CreatedBy: createdBy,
},
}
svc := NewAfterFlightInspectionService(repo)
flightInspectionID := make([]byte, 16)
for i := range flightInspectionID {
flightInspectionID[i] = byte(100 + i)
}
nr1 := "400"
row, err := svc.Upsert(context.Background(), flightInspectionID, &afterflightinspection.UpsertRequest{
OilEngineNR1Checked: &nr1,
})
if err != nil {
t.Fatalf("Upsert returned error: %v", err)
}
// The saved row must reuse the existing identity so GORM performs an UPDATE
// instead of inserting a duplicate flight_inspection_id (the original bug).
if string(repo.upserted.ID) != string(existingID) {
t.Fatalf("expected upsert to reuse existing ID %x, got %x", existingID, repo.upserted.ID)
}
if !repo.upserted.CreatedAt.Equal(createdAt) {
t.Fatalf("expected created_at to be preserved, got %v", repo.upserted.CreatedAt)
}
if string(repo.upserted.CreatedBy) != string(createdBy) {
t.Fatalf("expected created_by to be preserved, got %x", repo.upserted.CreatedBy)
}
if row == nil || string(row.ID) != string(existingID) {
t.Fatal("expected returned row to carry existing ID")
}
}
func TestAfterFlightInspectionServiceUpsertInsertsWhenAbsent(t *testing.T) {
repo := &afterFlightInspectionRepoStub{
exists: true,
caps: &afterflightinspection.HelicopterCapabilities{NR1: true},
}
svc := NewAfterFlightInspectionService(repo)
flightInspectionID := make([]byte, 16)
for i := range flightInspectionID {
flightInspectionID[i] = byte(50 + i)
}
nr1 := "400"
if _, err := svc.Upsert(context.Background(), flightInspectionID, &afterflightinspection.UpsertRequest{
OilEngineNR1Checked: &nr1,
}); err != nil {
t.Fatalf("Upsert returned error: %v", err)
}
// No existing row: a new INSERT (empty ID, BeforeCreate mints a UUID).
if len(repo.upserted.ID) != 0 {
t.Fatalf("expected empty ID for new row, got %x", repo.upserted.ID)
}
if string(repo.upserted.FlightInspectionID) != string(flightInspectionID) {
t.Fatal("expected flight_inspection_id to be set")
}
}
func TestAfterFlightInspectionServiceValidateRequestRejectsUnsupportedChecks(t *testing.T) {
svc := NewAfterFlightInspectionService(&afterFlightInspectionRepoStub{})
yes := "checked"
req := &afterflightinspection.UpsertRequest{
OilEngineNR1Checked: &yes,
}
caps := &afterflightinspection.HelicopterCapabilities{}
if err := svc.validateRequest(req, caps); err == nil {
t.Fatal("expected unsupported nr1 check to be rejected")
}
}

View File

@@ -0,0 +1,289 @@
package service
import (
"bytes"
"context"
"errors"
"gorm.io/gorm"
airrescuechecklist "wucher/internal/domain/air_rescue_checklist"
)
type AirRescuerChecklistService struct {
repo airrescuechecklist.Repository
}
func NewAirRescuerChecklistService(repo airrescuechecklist.Repository) *AirRescuerChecklistService {
return &AirRescuerChecklistService{repo: repo}
}
func (s *AirRescuerChecklistService) CreateBulk(ctx context.Context, rows []airrescuechecklist.ChecklistCreateInput) error {
if len(rows) == 0 {
return nil
}
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
for i := range rows {
header := &rows[i].Checklist
if header.Position <= 0 {
nextPos, err := txRepo.NextChecklistPosition(ctx, header.HEMSBaseID, header.ScopeCode)
if err != nil {
return err
}
header.Position = nextPos
}
if err := txRepo.CreateChecklist(ctx, header); err != nil {
return err
}
var nextItemPos int
itemNextInitialized := false
for j := range rows[i].Items {
item := &rows[i].Items[j]
item.ChecklistID = append([]byte(nil), header.ID...)
if item.Position <= 0 {
if !itemNextInitialized {
pos, err := txRepo.NextChecklistItemPosition(ctx, header.ID)
if err != nil {
return err
}
nextItemPos = pos
itemNextInitialized = true
}
item.Position = nextItemPos
nextItemPos++
}
if err := txRepo.CreateChecklistItem(ctx, item); err != nil {
return err
}
}
}
return nil
})
}
func (s *AirRescuerChecklistService) UpdateChecklist(ctx context.Context, row *airrescuechecklist.AirRescuerChecklist) error {
if row == nil {
return gorm.ErrInvalidData
}
row.Position = normalizeChecklistPosition(row.Position)
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistByID(ctx, row.ID); err != nil {
return err
}
return txRepo.UpdateChecklist(ctx, row)
})
}
func (s *AirRescuerChecklistService) UpdateChecklistWithNewItems(
ctx context.Context,
row *airrescuechecklist.AirRescuerChecklist,
newItems []airrescuechecklist.AirRescuerChecklistItem,
) error {
if row == nil && len(newItems) == 0 {
return gorm.ErrInvalidData
}
checklistID := []byte(nil)
if row != nil {
row.Position = normalizeChecklistPosition(row.Position)
checklistID = append([]byte(nil), row.ID...)
}
if len(checklistID) == 0 && len(newItems) > 0 {
checklistID = append([]byte(nil), newItems[0].ChecklistID...)
}
if len(checklistID) == 0 {
return gorm.ErrInvalidData
}
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistByID(ctx, checklistID); err != nil {
return err
}
if row != nil {
if err := txRepo.UpdateChecklist(ctx, row); err != nil {
return err
}
}
if len(newItems) == 0 {
return nil
}
nextItemPos := 0
itemNextInitialized := false
for i := range newItems {
newItems[i].ChecklistID = append([]byte(nil), checklistID...)
if newItems[i].Position <= 0 {
if !itemNextInitialized {
pos, err := txRepo.NextChecklistItemPosition(ctx, checklistID)
if err != nil {
return err
}
nextItemPos = pos
itemNextInitialized = true
}
newItems[i].Position = nextItemPos
nextItemPos++
}
}
return txRepo.CreateChecklistItems(ctx, newItems)
})
}
func (s *AirRescuerChecklistService) DeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistByID(ctx, id); err != nil {
return err
}
if err := txRepo.SoftDeleteChecklist(ctx, id, deletedBy); err != nil {
return err
}
return txRepo.SoftDeleteItemsByChecklistID(ctx, id, deletedBy)
})
}
func (s *AirRescuerChecklistService) CreateChecklistItem(ctx context.Context, row *airrescuechecklist.AirRescuerChecklistItem) error {
if row == nil {
return gorm.ErrInvalidData
}
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistByID(ctx, row.ChecklistID); err != nil {
return err
}
if row.Position <= 0 {
nextPos, err := txRepo.NextChecklistItemPosition(ctx, row.ChecklistID)
if err != nil {
return err
}
row.Position = nextPos
}
return txRepo.CreateChecklistItem(ctx, row)
})
}
func (s *AirRescuerChecklistService) CreateChecklistItems(ctx context.Context, checklistID []byte, rows []airrescuechecklist.AirRescuerChecklistItem) error {
if len(rows) == 0 {
return nil
}
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistByID(ctx, checklistID); err != nil {
return err
}
nextItemPos := 0
itemNextInitialized := false
for i := range rows {
rows[i].ChecklistID = append([]byte(nil), checklistID...)
if rows[i].Position <= 0 {
if !itemNextInitialized {
pos, err := txRepo.NextChecklistItemPosition(ctx, checklistID)
if err != nil {
return err
}
nextItemPos = pos
itemNextInitialized = true
}
rows[i].Position = nextItemPos
nextItemPos++
}
}
return txRepo.CreateChecklistItems(ctx, rows)
})
}
func (s *AirRescuerChecklistService) ReorderChecklistItems(
ctx context.Context,
checklistID []byte,
rows []airrescuechecklist.ChecklistItemReorderInput,
updatedBy []byte,
) error {
if len(rows) == 0 {
return gorm.ErrInvalidData
}
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistByID(ctx, checklistID); err != nil {
return err
}
seenItemIDs := make(map[string]struct{}, len(rows))
for i := range rows {
itemID := rows[i].ID
if len(itemID) == 0 {
return gorm.ErrInvalidData
}
itemKey := string(itemID)
if _, exists := seenItemIDs[itemKey]; exists {
return errors.New("duplicate item_uuid in reorder payload")
}
seenItemIDs[itemKey] = struct{}{}
current, err := txRepo.LockChecklistItemByID(ctx, itemID)
if err != nil {
return err
}
if !bytes.Equal(current.ChecklistID, checklistID) {
return gorm.ErrRecordNotFound
}
if err := txRepo.UpdateChecklistItem(ctx, &airrescuechecklist.AirRescuerChecklistItem{
ID: append([]byte(nil), itemID...),
Name: current.Name,
Position: normalizeChecklistPosition(rows[i].Position),
UpdatedBy: append([]byte(nil), updatedBy...),
}); err != nil {
return err
}
}
return nil
})
}
func (s *AirRescuerChecklistService) UpdateChecklistItem(ctx context.Context, row *airrescuechecklist.AirRescuerChecklistItem) error {
if row == nil {
return gorm.ErrInvalidData
}
row.Position = normalizeChecklistPosition(row.Position)
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistItemByID(ctx, row.ID); err != nil {
return err
}
return txRepo.UpdateChecklistItem(ctx, row)
})
}
func (s *AirRescuerChecklistService) DeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.InTx(ctx, func(txRepo airrescuechecklist.TxRepository) error {
if _, err := txRepo.LockChecklistItemByID(ctx, id); err != nil {
return err
}
return txRepo.SoftDeleteChecklistItem(ctx, id, deletedBy)
})
}
func (s *AirRescuerChecklistService) GetChecklistByID(ctx context.Context, id []byte) (*airrescuechecklist.ChecklistHeaderView, error) {
return s.repo.GetChecklistByID(ctx, id)
}
func (s *AirRescuerChecklistService) GetChecklistItemByID(ctx context.Context, id []byte) (*airrescuechecklist.ChecklistItemView, error) {
return s.repo.GetChecklistItemByID(ctx, id)
}
func (s *AirRescuerChecklistService) ListChecklistHeaders(ctx context.Context, filter airrescuechecklist.ChecklistHeaderFilter) ([]airrescuechecklist.ChecklistHeaderView, error) {
return s.repo.ListChecklistHeaders(ctx, filter)
}
func (s *AirRescuerChecklistService) ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]airrescuechecklist.ChecklistItemView, error) {
return s.repo.ListChecklistItemsByChecklistIDs(ctx, checklistIDs)
}
func (s *AirRescuerChecklistService) ListHEMSBases(ctx context.Context, filter airrescuechecklist.HEMSBaseFilter) ([]airrescuechecklist.HEMSBaseView, error) {
return s.repo.ListHEMSBases(ctx, filter)
}
func normalizeChecklistPosition(v int) int {
if v <= 0 {
return 1
}
return v
}

View File

@@ -0,0 +1,986 @@
package service
import (
"bytes"
"context"
"errors"
"testing"
"gorm.io/gorm"
airrescuechecklist "wucher/internal/domain/air_rescue_checklist"
)
type airRescuerChecklistTxRepoMock struct {
createChecklistCalled bool
createChecklistRow *airrescuechecklist.AirRescuerChecklist
createChecklistErr error
nextChecklistPosition int
nextChecklistPosErr error
nextChecklistPosCalls int
lockChecklistByIDCalled bool
lockChecklistID []byte
lockChecklistByIDErr error
updateChecklistCalled bool
updateChecklistRow *airrescuechecklist.AirRescuerChecklist
updateChecklistErr error
softDeleteChecklistCalled bool
softDeleteChecklistID []byte
softDeleteChecklistBy []byte
softDeleteChecklistErr error
softDeleteItemsByChecklistCalled bool
softDeleteItemsByChecklistID []byte
softDeleteItemsByChecklistBy []byte
softDeleteItemsByChecklistErr error
createChecklistItemCalled bool
createChecklistItemRow *airrescuechecklist.AirRescuerChecklistItem
createChecklistItemErr error
createChecklistItemsCalled bool
createChecklistItemsRows []airrescuechecklist.AirRescuerChecklistItem
createChecklistItemsErr error
nextChecklistItemPosition int
nextChecklistItemPosErr error
nextChecklistItemPosCalls int
lockChecklistItemByIDCalled bool
lockChecklistItemID []byte
lockChecklistItemIDs [][]byte
lockChecklistItemRows map[string]*airrescuechecklist.AirRescuerChecklistItem
lockChecklistItemByIDErr error
updateChecklistItemCalled bool
updateChecklistItemRow *airrescuechecklist.AirRescuerChecklistItem
updateChecklistItemErr error
softDeleteChecklistItemCalled bool
softDeleteChecklistItemID []byte
softDeleteChecklistItemBy []byte
softDeleteChecklistItemErr error
}
func (m *airRescuerChecklistTxRepoMock) CreateChecklist(_ context.Context, row *airrescuechecklist.AirRescuerChecklist) error {
m.createChecklistCalled = true
m.createChecklistRow = row
return m.createChecklistErr
}
func (m *airRescuerChecklistTxRepoMock) NextChecklistPosition(_ context.Context, _ []byte, _ string) (int, error) {
m.nextChecklistPosCalls++
if m.nextChecklistPosErr != nil {
return 0, m.nextChecklistPosErr
}
if m.nextChecklistPosition > 0 {
return m.nextChecklistPosition, nil
}
return 1, nil
}
func (m *airRescuerChecklistTxRepoMock) LockChecklistByID(_ context.Context, id []byte) (*airrescuechecklist.AirRescuerChecklist, error) {
m.lockChecklistByIDCalled = true
m.lockChecklistID = append([]byte(nil), id...)
if m.lockChecklistByIDErr != nil {
return nil, m.lockChecklistByIDErr
}
return &airrescuechecklist.AirRescuerChecklist{ID: append([]byte(nil), id...)}, nil
}
func (m *airRescuerChecklistTxRepoMock) UpdateChecklist(_ context.Context, row *airrescuechecklist.AirRescuerChecklist) error {
m.updateChecklistCalled = true
m.updateChecklistRow = row
return m.updateChecklistErr
}
func (m *airRescuerChecklistTxRepoMock) SoftDeleteChecklist(_ context.Context, id []byte, deletedBy []byte) error {
m.softDeleteChecklistCalled = true
m.softDeleteChecklistID = append([]byte(nil), id...)
m.softDeleteChecklistBy = append([]byte(nil), deletedBy...)
return m.softDeleteChecklistErr
}
func (m *airRescuerChecklistTxRepoMock) SoftDeleteItemsByChecklistID(_ context.Context, checklistID []byte, deletedBy []byte) error {
m.softDeleteItemsByChecklistCalled = true
m.softDeleteItemsByChecklistID = append([]byte(nil), checklistID...)
m.softDeleteItemsByChecklistBy = append([]byte(nil), deletedBy...)
return m.softDeleteItemsByChecklistErr
}
func (m *airRescuerChecklistTxRepoMock) CreateChecklistItem(_ context.Context, row *airrescuechecklist.AirRescuerChecklistItem) error {
m.createChecklistItemCalled = true
m.createChecklistItemRow = row
return m.createChecklistItemErr
}
func (m *airRescuerChecklistTxRepoMock) CreateChecklistItems(_ context.Context, rows []airrescuechecklist.AirRescuerChecklistItem) error {
m.createChecklistItemsCalled = true
m.createChecklistItemsRows = append([]airrescuechecklist.AirRescuerChecklistItem(nil), rows...)
return m.createChecklistItemsErr
}
func (m *airRescuerChecklistTxRepoMock) NextChecklistItemPosition(_ context.Context, _ []byte) (int, error) {
m.nextChecklistItemPosCalls++
if m.nextChecklistItemPosErr != nil {
return 0, m.nextChecklistItemPosErr
}
if m.nextChecklistItemPosition > 0 {
return m.nextChecklistItemPosition, nil
}
return 1, nil
}
func (m *airRescuerChecklistTxRepoMock) LockChecklistItemByID(_ context.Context, id []byte) (*airrescuechecklist.AirRescuerChecklistItem, error) {
m.lockChecklistItemByIDCalled = true
m.lockChecklistItemID = append([]byte(nil), id...)
m.lockChecklistItemIDs = append(m.lockChecklistItemIDs, append([]byte(nil), id...))
if m.lockChecklistItemByIDErr != nil {
return nil, m.lockChecklistItemByIDErr
}
if m.lockChecklistItemRows != nil {
if row, ok := m.lockChecklistItemRows[string(id)]; ok {
dup := *row
dup.ID = append([]byte(nil), row.ID...)
dup.ChecklistID = append([]byte(nil), row.ChecklistID...)
return &dup, nil
}
}
return &airrescuechecklist.AirRescuerChecklistItem{ID: append([]byte(nil), id...)}, nil
}
func (m *airRescuerChecklistTxRepoMock) UpdateChecklistItem(_ context.Context, row *airrescuechecklist.AirRescuerChecklistItem) error {
m.updateChecklistItemCalled = true
m.updateChecklistItemRow = row
return m.updateChecklistItemErr
}
func (m *airRescuerChecklistTxRepoMock) SoftDeleteChecklistItem(_ context.Context, id []byte, deletedBy []byte) error {
m.softDeleteChecklistItemCalled = true
m.softDeleteChecklistItemID = append([]byte(nil), id...)
m.softDeleteChecklistItemBy = append([]byte(nil), deletedBy...)
return m.softDeleteChecklistItemErr
}
type airRescuerChecklistRepoMock struct {
txRepo *airRescuerChecklistTxRepoMock
inTxCalled bool
getChecklistByIDCalled bool
getChecklistByIDID []byte
getChecklistByIDRow *airrescuechecklist.ChecklistHeaderView
getChecklistByIDErr error
getChecklistItemByIDCalled bool
getChecklistItemByIDID []byte
getChecklistItemByIDRow *airrescuechecklist.ChecklistItemView
getChecklistItemByIDErr error
listChecklistHeadersCalled bool
listChecklistHeadersFilter airrescuechecklist.ChecklistHeaderFilter
listChecklistHeadersRows []airrescuechecklist.ChecklistHeaderView
listChecklistHeadersErr error
listChecklistItemsByIDsCalled bool
listChecklistItemsByIDsArg [][]byte
listChecklistItemsByIDsRows []airrescuechecklist.ChecklistItemView
listChecklistItemsByIDsErr error
listHEMSBasesCalled bool
listHEMSBasesFilter airrescuechecklist.HEMSBaseFilter
listHEMSBasesRows []airrescuechecklist.HEMSBaseView
listHEMSBasesErr error
}
func (m *airRescuerChecklistRepoMock) InTx(ctx context.Context, fn func(txRepo airrescuechecklist.TxRepository) error) error {
m.inTxCalled = true
return fn(m.txRepo)
}
func (m *airRescuerChecklistRepoMock) GetChecklistByID(_ context.Context, id []byte) (*airrescuechecklist.ChecklistHeaderView, error) {
m.getChecklistByIDCalled = true
m.getChecklistByIDID = append([]byte(nil), id...)
return m.getChecklistByIDRow, m.getChecklistByIDErr
}
func (m *airRescuerChecklistRepoMock) GetChecklistItemByID(_ context.Context, id []byte) (*airrescuechecklist.ChecklistItemView, error) {
m.getChecklistItemByIDCalled = true
m.getChecklistItemByIDID = append([]byte(nil), id...)
return m.getChecklistItemByIDRow, m.getChecklistItemByIDErr
}
func (m *airRescuerChecklistRepoMock) ListChecklistHeaders(_ context.Context, filter airrescuechecklist.ChecklistHeaderFilter) ([]airrescuechecklist.ChecklistHeaderView, error) {
m.listChecklistHeadersCalled = true
m.listChecklistHeadersFilter = filter
return m.listChecklistHeadersRows, m.listChecklistHeadersErr
}
func (m *airRescuerChecklistRepoMock) ListChecklistItemsByChecklistIDs(_ context.Context, checklistIDs [][]byte) ([]airrescuechecklist.ChecklistItemView, error) {
m.listChecklistItemsByIDsCalled = true
m.listChecklistItemsByIDsArg = append([][]byte(nil), checklistIDs...)
return m.listChecklistItemsByIDsRows, m.listChecklistItemsByIDsErr
}
func (m *airRescuerChecklistRepoMock) ListHEMSBases(_ context.Context, filter airrescuechecklist.HEMSBaseFilter) ([]airrescuechecklist.HEMSBaseView, error) {
m.listHEMSBasesCalled = true
m.listHEMSBasesFilter = filter
return m.listHEMSBasesRows, m.listHEMSBasesErr
}
func TestAirRescuerChecklistServiceCreateChecklistItems(t *testing.T) {
checklistID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{nextChecklistItemPosition: 7}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
rows := []airrescuechecklist.AirRescuerChecklistItem{
{Name: "Check oxygen", Position: 0},
{Name: "Check radio", Position: 2},
}
if err := svc.CreateChecklistItems(context.Background(), checklistID, rows); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.inTxCalled {
t.Fatalf("expected CreateChecklistItems to run in transaction")
}
if !txRepo.lockChecklistByIDCalled {
t.Fatalf("expected checklist lock before create")
}
if !bytes.Equal(txRepo.lockChecklistID, checklistID) {
t.Fatalf("expected lock checklist id forwarded from path param")
}
if !txRepo.createChecklistItemsCalled {
t.Fatalf("expected bulk create items called")
}
if len(txRepo.createChecklistItemsRows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(txRepo.createChecklistItemsRows))
}
if !bytes.Equal(txRepo.createChecklistItemsRows[0].ChecklistID, checklistID) || !bytes.Equal(txRepo.createChecklistItemsRows[1].ChecklistID, checklistID) {
t.Fatalf("expected checklist_id derived from path for all rows")
}
if txRepo.createChecklistItemsRows[0].Position != 7 {
t.Fatalf("expected first position auto-assigned to 7, got %d", txRepo.createChecklistItemsRows[0].Position)
}
if txRepo.createChecklistItemsRows[1].Position != 2 {
t.Fatalf("expected second position stay 2, got %d", txRepo.createChecklistItemsRows[1].Position)
}
if txRepo.nextChecklistItemPosCalls != 1 {
t.Fatalf("expected next checklist item position to be read once, got %d", txRepo.nextChecklistItemPosCalls)
}
}
func TestAirRescuerChecklistServiceCreateChecklistItems_AllowsDuplicatePosition(t *testing.T) {
checklistID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
rows := []airrescuechecklist.AirRescuerChecklistItem{
{Name: "Battery", Position: 2},
{Name: "Radio", Position: 2},
}
if err := svc.CreateChecklistItems(context.Background(), checklistID, rows); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !txRepo.createChecklistItemsCalled {
t.Fatalf("expected bulk create called")
}
if len(txRepo.createChecklistItemsRows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(txRepo.createChecklistItemsRows))
}
if txRepo.createChecklistItemsRows[0].Position != 2 || txRepo.createChecklistItemsRows[1].Position != 2 {
t.Fatalf("expected duplicate position to be preserved, got %d and %d", txRepo.createChecklistItemsRows[0].Position, txRepo.createChecklistItemsRows[1].Position)
}
if txRepo.nextChecklistItemPosCalls != 0 {
t.Fatalf("expected no auto position query when all positions are provided")
}
}
func TestAirRescuerChecklistServiceCreateChecklistItemsErrorBranches(t *testing.T) {
checklistID := []byte("1234567890123456")
t.Run("empty rows", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.CreateChecklistItems(context.Background(), checklistID, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.inTxCalled {
t.Fatalf("expected no transaction when rows are empty")
}
})
t.Run("lock checklist failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistByIDErr: errors.New("lock failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.CreateChecklistItems(context.Background(), checklistID, []airrescuechecklist.AirRescuerChecklistItem{{Name: "A"}})
if err == nil || err.Error() != "lock failed" {
t.Fatalf("expected lock error, got %v", err)
}
if txRepo.createChecklistItemsCalled {
t.Fatalf("expected create items not called when lock fails")
}
})
t.Run("create bulk failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{createChecklistItemsErr: errors.New("create failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.CreateChecklistItems(context.Background(), checklistID, []airrescuechecklist.AirRescuerChecklistItem{{Name: "A"}})
if err == nil || err.Error() != "create failed" {
t.Fatalf("expected create error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceReorderChecklistItems(t *testing.T) {
checklistID := []byte("1234567890123456")
itemA := []byte("1111111111111111")
itemB := []byte("2222222222222222")
updatedBy := []byte("aaaaaaaaaaaaaaaa")
txRepo := &airRescuerChecklistTxRepoMock{
lockChecklistItemRows: map[string]*airrescuechecklist.AirRescuerChecklistItem{
string(itemA): {ID: append([]byte(nil), itemA...), ChecklistID: append([]byte(nil), checklistID...), Name: "Item A", Position: 1},
string(itemB): {ID: append([]byte(nil), itemB...), ChecklistID: append([]byte(nil), checklistID...), Name: "Item B", Position: 2},
},
}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.ReorderChecklistItems(context.Background(), checklistID, []airrescuechecklist.ChecklistItemReorderInput{
{ID: append([]byte(nil), itemA...), Position: 2},
{ID: append([]byte(nil), itemB...), Position: 1},
}, updatedBy)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.inTxCalled {
t.Fatalf("expected transaction for reorder")
}
if !txRepo.lockChecklistByIDCalled {
t.Fatalf("expected checklist lock for reorder")
}
if len(txRepo.lockChecklistItemIDs) != 2 {
t.Fatalf("expected two item locks, got %d", len(txRepo.lockChecklistItemIDs))
}
if !txRepo.updateChecklistItemCalled {
t.Fatalf("expected item updates to be executed")
}
if txRepo.updateChecklistItemRow == nil {
t.Fatalf("expected update row to be set")
}
if txRepo.updateChecklistItemRow.Position != 1 {
t.Fatalf("expected last update position to be 1, got %d", txRepo.updateChecklistItemRow.Position)
}
if !bytes.Equal(txRepo.updateChecklistItemRow.UpdatedBy, updatedBy) {
t.Fatalf("expected updated_by to be propagated")
}
}
func TestAirRescuerChecklistServiceReorderChecklistItemsBranches(t *testing.T) {
checklistID := []byte("1234567890123456")
itemID := []byte("1111111111111111")
otherChecklistID := []byte("2222222222222222")
t.Run("empty rows", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.ReorderChecklistItems(context.Background(), checklistID, nil, nil)
if !errors.Is(err, gorm.ErrInvalidData) {
t.Fatalf("expected invalid data, got %v", err)
}
if repo.inTxCalled {
t.Fatalf("expected no transaction when rows are empty")
}
})
t.Run("lock checklist failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistByIDErr: errors.New("lock checklist failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.ReorderChecklistItems(context.Background(), checklistID, []airrescuechecklist.ChecklistItemReorderInput{{ID: itemID, Position: 1}}, nil)
if err == nil || err.Error() != "lock checklist failed" {
t.Fatalf("expected checklist lock error, got %v", err)
}
})
t.Run("duplicate item ids in payload", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{
lockChecklistItemRows: map[string]*airrescuechecklist.AirRescuerChecklistItem{
string(itemID): {ID: append([]byte(nil), itemID...), ChecklistID: append([]byte(nil), checklistID...), Name: "Item", Position: 1},
},
}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.ReorderChecklistItems(context.Background(), checklistID, []airrescuechecklist.ChecklistItemReorderInput{
{ID: append([]byte(nil), itemID...), Position: 1},
{ID: append([]byte(nil), itemID...), Position: 2},
}, nil)
if err == nil || err.Error() != "duplicate item_uuid in reorder payload" {
t.Fatalf("expected duplicate item error, got %v", err)
}
})
t.Run("item belongs to different checklist", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{
lockChecklistItemRows: map[string]*airrescuechecklist.AirRescuerChecklistItem{
string(itemID): {ID: append([]byte(nil), itemID...), ChecklistID: append([]byte(nil), otherChecklistID...), Name: "Item", Position: 1},
},
}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.ReorderChecklistItems(context.Background(), checklistID, []airrescuechecklist.ChecklistItemReorderInput{{ID: itemID, Position: 3}}, nil)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("expected not found for different checklist item, got %v", err)
}
})
t.Run("update item failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{
lockChecklistItemRows: map[string]*airrescuechecklist.AirRescuerChecklistItem{
string(itemID): {ID: append([]byte(nil), itemID...), ChecklistID: append([]byte(nil), checklistID...), Name: "Item", Position: 1},
},
updateChecklistItemErr: errors.New("update failed"),
}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.ReorderChecklistItems(context.Background(), checklistID, []airrescuechecklist.ChecklistItemReorderInput{{ID: itemID, Position: 1}}, nil)
if err == nil || err.Error() != "update failed" {
t.Fatalf("expected update failure, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceUpdateChecklistWithNewItems(t *testing.T) {
checklistID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{nextChecklistItemPosition: 8}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
row := &airrescuechecklist.AirRescuerChecklist{
ID: append([]byte(nil), checklistID...),
Position: 0,
}
newItems := []airrescuechecklist.AirRescuerChecklistItem{
{Name: "Check battery", Position: 0},
{Name: "Check stretcher lock", Position: 4},
}
if err := svc.UpdateChecklistWithNewItems(context.Background(), row, newItems); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.inTxCalled {
t.Fatalf("expected update flow to run in transaction")
}
if !txRepo.lockChecklistByIDCalled {
t.Fatalf("expected checklist lock")
}
if !bytes.Equal(txRepo.lockChecklistID, checklistID) {
t.Fatalf("expected lock ID from checklist")
}
if !txRepo.updateChecklistCalled {
t.Fatalf("expected header update called")
}
if txRepo.updateChecklistRow == nil || txRepo.updateChecklistRow.Position != 1 {
t.Fatalf("expected header position normalized to 1")
}
if !txRepo.createChecklistItemsCalled {
t.Fatalf("expected new items append called")
}
if len(txRepo.createChecklistItemsRows) != 2 {
t.Fatalf("expected 2 appended items")
}
if !bytes.Equal(txRepo.createChecklistItemsRows[0].ChecklistID, checklistID) || !bytes.Equal(txRepo.createChecklistItemsRows[1].ChecklistID, checklistID) {
t.Fatalf("expected all new_items to use checklist ID")
}
if txRepo.createChecklistItemsRows[0].Position != 8 {
t.Fatalf("expected first new item position auto-assigned to 8")
}
if txRepo.createChecklistItemsRows[1].Position != 4 {
t.Fatalf("expected second new item position remains 4")
}
if txRepo.nextChecklistItemPosCalls != 1 {
t.Fatalf("expected next checklist item position to be read once, got %d", txRepo.nextChecklistItemPosCalls)
}
}
func TestAirRescuerChecklistServiceUpdateChecklistWithNewItemsBranches(t *testing.T) {
checklistID := []byte("1234567890123456")
t.Run("new-items-only", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistWithNewItems(
context.Background(),
nil,
[]airrescuechecklist.AirRescuerChecklistItem{{ChecklistID: append([]byte(nil), checklistID...), Name: "A"}},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if txRepo.updateChecklistCalled {
t.Fatalf("expected no header update when row is nil")
}
if !txRepo.createChecklistItemsCalled {
t.Fatalf("expected append items called")
}
})
t.Run("header-only", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistWithNewItems(
context.Background(),
&airrescuechecklist.AirRescuerChecklist{ID: append([]byte(nil), checklistID...), Position: 2},
nil,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !txRepo.updateChecklistCalled {
t.Fatalf("expected header update called")
}
if txRepo.createChecklistItemsCalled {
t.Fatalf("expected no append items when new_items empty")
}
})
t.Run("invalid payload", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.UpdateChecklistWithNewItems(context.Background(), nil, nil); err == nil {
t.Fatalf("expected invalid data error")
}
})
t.Run("lock failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistByIDErr: errors.New("lock failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistWithNewItems(context.Background(), &airrescuechecklist.AirRescuerChecklist{ID: append([]byte(nil), checklistID...), Position: 1}, nil)
if err == nil || err.Error() != "lock failed" {
t.Fatalf("expected lock error, got %v", err)
}
})
t.Run("update failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{updateChecklistErr: errors.New("update failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistWithNewItems(context.Background(), &airrescuechecklist.AirRescuerChecklist{ID: append([]byte(nil), checklistID...), Position: 1}, nil)
if err == nil || err.Error() != "update failed" {
t.Fatalf("expected update error, got %v", err)
}
if txRepo.createChecklistItemsCalled {
t.Fatalf("expected append not called when update failed")
}
})
t.Run("append failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{createChecklistItemsErr: errors.New("append failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistWithNewItems(
context.Background(),
&airrescuechecklist.AirRescuerChecklist{ID: append([]byte(nil), checklistID...), Position: 1},
[]airrescuechecklist.AirRescuerChecklistItem{{Name: "A"}},
)
if err == nil || err.Error() != "append failed" {
t.Fatalf("expected append error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceUpdateChecklistWithNewItemsInvalidChecklistIDFromItems(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{nextChecklistPosition: 5, nextChecklistItemPosition: 9}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistWithNewItems(context.Background(), nil, []airrescuechecklist.AirRescuerChecklistItem{{Name: "A"}})
if err == nil {
t.Fatalf("expected invalid data error when new_items checklist id is empty")
}
}
func TestAirRescuerChecklistServiceCreateBulk(t *testing.T) {
checklistID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{nextChecklistPosition: 5, nextChecklistItemPosition: 9}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
rows := []airrescuechecklist.ChecklistCreateInput{
{
Checklist: airrescuechecklist.AirRescuerChecklist{
ID: append([]byte(nil), checklistID...),
Position: 0,
},
Items: []airrescuechecklist.AirRescuerChecklistItem{
{Name: "Oxygen", Position: 0},
},
},
}
if err := svc.CreateBulk(context.Background(), rows); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.inTxCalled || !txRepo.createChecklistCalled || !txRepo.createChecklistItemCalled {
t.Fatalf("expected checklist and item created in transaction")
}
if txRepo.createChecklistRow == nil || txRepo.createChecklistRow.Position != 5 {
t.Fatalf("expected checklist position auto-assigned to 5")
}
if txRepo.createChecklistItemRow == nil || txRepo.createChecklistItemRow.Position != 9 {
t.Fatalf("expected item position auto-assigned to 9")
}
if !bytes.Equal(txRepo.createChecklistItemRow.ChecklistID, checklistID) {
t.Fatalf("expected item checklist id copied from created checklist")
}
if txRepo.nextChecklistPosCalls != 1 {
t.Fatalf("expected next checklist position to be read once, got %d", txRepo.nextChecklistPosCalls)
}
if txRepo.nextChecklistItemPosCalls != 1 {
t.Fatalf("expected next checklist item position to be read once, got %d", txRepo.nextChecklistItemPosCalls)
}
}
func TestAirRescuerChecklistServiceCreateBulkBranches(t *testing.T) {
t.Run("empty rows", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.CreateBulk(context.Background(), nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.inTxCalled {
t.Fatalf("expected no transaction for empty rows")
}
})
t.Run("create checklist failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{createChecklistErr: errors.New("create checklist failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.CreateBulk(context.Background(), []airrescuechecklist.ChecklistCreateInput{{Checklist: airrescuechecklist.AirRescuerChecklist{}}})
if err == nil || err.Error() != "create checklist failed" {
t.Fatalf("expected create checklist error, got %v", err)
}
})
t.Run("create item failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{createChecklistItemErr: errors.New("create item failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.CreateBulk(context.Background(), []airrescuechecklist.ChecklistCreateInput{{
Checklist: airrescuechecklist.AirRescuerChecklist{},
Items: []airrescuechecklist.AirRescuerChecklistItem{{Name: "A"}},
}})
if err == nil || err.Error() != "create item failed" {
t.Fatalf("expected create item error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceUpdateChecklist(t *testing.T) {
checklistID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{nextChecklistItemPosition: 6}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
row := &airrescuechecklist.AirRescuerChecklist{ID: append([]byte(nil), checklistID...), Position: 0}
if err := svc.UpdateChecklist(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row.Position != 1 {
t.Fatalf("expected position normalized to 1")
}
if !txRepo.lockChecklistByIDCalled || !txRepo.updateChecklistCalled {
t.Fatalf("expected lock + update executed")
}
}
func TestAirRescuerChecklistServiceUpdateChecklistBranches(t *testing.T) {
t.Run("nil row", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.UpdateChecklist(context.Background(), nil); err == nil {
t.Fatalf("expected invalid data error")
}
})
t.Run("lock failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistByIDErr: errors.New("lock failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklist(context.Background(), &airrescuechecklist.AirRescuerChecklist{ID: []byte("1234567890123456"), Position: 1})
if err == nil || err.Error() != "lock failed" {
t.Fatalf("expected lock error, got %v", err)
}
})
t.Run("update failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{updateChecklistErr: errors.New("update failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklist(context.Background(), &airrescuechecklist.AirRescuerChecklist{ID: []byte("1234567890123456"), Position: 1})
if err == nil || err.Error() != "update failed" {
t.Fatalf("expected update error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceDeleteChecklist(t *testing.T) {
id := []byte("1234567890123456")
deletedBy := []byte("abcdefghijklmnop")
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.DeleteChecklist(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !txRepo.lockChecklistByIDCalled || !txRepo.softDeleteChecklistCalled || !txRepo.softDeleteItemsByChecklistCalled {
t.Fatalf("expected lock + soft delete header + soft delete items")
}
if !bytes.Equal(txRepo.softDeleteChecklistID, id) || !bytes.Equal(txRepo.softDeleteItemsByChecklistID, id) {
t.Fatalf("expected delete id propagated")
}
}
func TestAirRescuerChecklistServiceDeleteChecklistBranches(t *testing.T) {
id := []byte("1234567890123456")
deletedBy := []byte("abcdefghijklmnop")
t.Run("lock failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistByIDErr: errors.New("lock failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.DeleteChecklist(context.Background(), id, deletedBy)
if err == nil || err.Error() != "lock failed" {
t.Fatalf("expected lock error, got %v", err)
}
})
t.Run("soft delete header failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{softDeleteChecklistErr: errors.New("delete header failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.DeleteChecklist(context.Background(), id, deletedBy)
if err == nil || err.Error() != "delete header failed" {
t.Fatalf("expected header delete error, got %v", err)
}
})
t.Run("soft delete items failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{softDeleteItemsByChecklistErr: errors.New("delete items failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.DeleteChecklist(context.Background(), id, deletedBy)
if err == nil || err.Error() != "delete items failed" {
t.Fatalf("expected items delete error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceCreateChecklistItem(t *testing.T) {
checklistID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{nextChecklistItemPosition: 6}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
row := &airrescuechecklist.AirRescuerChecklistItem{ChecklistID: append([]byte(nil), checklistID...), Position: 0}
if err := svc.CreateChecklistItem(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !txRepo.lockChecklistByIDCalled || !txRepo.createChecklistItemCalled {
t.Fatalf("expected lock + create item")
}
if row.Position != 6 {
t.Fatalf("expected position auto-assigned")
}
}
func TestAirRescuerChecklistServiceCreateChecklistItemBranches(t *testing.T) {
t.Run("nil row", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.CreateChecklistItem(context.Background(), nil); err == nil {
t.Fatalf("expected invalid data error")
}
})
t.Run("lock failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistByIDErr: errors.New("lock failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.CreateChecklistItem(context.Background(), &airrescuechecklist.AirRescuerChecklistItem{ChecklistID: []byte("1234567890123456")})
if err == nil || err.Error() != "lock failed" {
t.Fatalf("expected lock error, got %v", err)
}
})
t.Run("create failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{createChecklistItemErr: errors.New("create failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.CreateChecklistItem(context.Background(), &airrescuechecklist.AirRescuerChecklistItem{ChecklistID: []byte("1234567890123456")})
if err == nil || err.Error() != "create failed" {
t.Fatalf("expected create error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceUpdateChecklistItem(t *testing.T) {
itemID := []byte("1234567890123456")
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
row := &airrescuechecklist.AirRescuerChecklistItem{ID: append([]byte(nil), itemID...), Position: 0}
if err := svc.UpdateChecklistItem(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !txRepo.lockChecklistItemByIDCalled || !txRepo.updateChecklistItemCalled {
t.Fatalf("expected lock + update item")
}
if row.Position != 1 {
t.Fatalf("expected position normalized")
}
}
func TestAirRescuerChecklistServiceUpdateChecklistItemBranches(t *testing.T) {
t.Run("nil row", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.UpdateChecklistItem(context.Background(), nil); err == nil {
t.Fatalf("expected invalid data error")
}
})
t.Run("lock failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistItemByIDErr: errors.New("lock item failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistItem(context.Background(), &airrescuechecklist.AirRescuerChecklistItem{ID: []byte("1234567890123456")})
if err == nil || err.Error() != "lock item failed" {
t.Fatalf("expected lock item error, got %v", err)
}
})
t.Run("update failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{updateChecklistItemErr: errors.New("update item failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.UpdateChecklistItem(context.Background(), &airrescuechecklist.AirRescuerChecklistItem{ID: []byte("1234567890123456")})
if err == nil || err.Error() != "update item failed" {
t.Fatalf("expected update item error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceDeleteChecklistItem(t *testing.T) {
itemID := []byte("1234567890123456")
deletedBy := []byte("abcdefghijklmnop")
txRepo := &airRescuerChecklistTxRepoMock{}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
if err := svc.DeleteChecklistItem(context.Background(), itemID, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !txRepo.lockChecklistItemByIDCalled || !txRepo.softDeleteChecklistItemCalled {
t.Fatalf("expected lock + soft delete item")
}
if !bytes.Equal(txRepo.softDeleteChecklistItemID, itemID) {
t.Fatalf("expected deleted item id propagated")
}
}
func TestAirRescuerChecklistServiceDeleteChecklistItemBranches(t *testing.T) {
itemID := []byte("1234567890123456")
deletedBy := []byte("abcdefghijklmnop")
t.Run("lock failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{lockChecklistItemByIDErr: errors.New("lock item failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.DeleteChecklistItem(context.Background(), itemID, deletedBy)
if err == nil || err.Error() != "lock item failed" {
t.Fatalf("expected lock item error, got %v", err)
}
})
t.Run("delete failed", func(t *testing.T) {
txRepo := &airRescuerChecklistTxRepoMock{softDeleteChecklistItemErr: errors.New("delete item failed")}
repo := &airRescuerChecklistRepoMock{txRepo: txRepo}
svc := NewAirRescuerChecklistService(repo)
err := svc.DeleteChecklistItem(context.Background(), itemID, deletedBy)
if err == nil || err.Error() != "delete item failed" {
t.Fatalf("expected delete item error, got %v", err)
}
})
}
func TestAirRescuerChecklistServiceReadWrappers(t *testing.T) {
checklistID := []byte("1234567890123456")
itemID := []byte("abcdefghijklmnop")
headers := []airrescuechecklist.ChecklistHeaderView{{Title: "A"}}
items := []airrescuechecklist.ChecklistItemView{{Name: "I"}}
bases := []airrescuechecklist.HEMSBaseView{{BaseName: "B"}}
repo := &airRescuerChecklistRepoMock{
txRepo: &airRescuerChecklistTxRepoMock{},
getChecklistByIDRow: &airrescuechecklist.ChecklistHeaderView{ID: checklistID},
getChecklistItemByIDRow: &airrescuechecklist.ChecklistItemView{ID: itemID},
listChecklistHeadersRows: headers,
listChecklistItemsByIDsRows: items,
listHEMSBasesRows: bases,
}
svc := NewAirRescuerChecklistService(repo)
if _, err := svc.GetChecklistByID(context.Background(), checklistID); err != nil {
t.Fatalf("GetChecklistByID error: %v", err)
}
if _, err := svc.GetChecklistItemByID(context.Background(), itemID); err != nil {
t.Fatalf("GetChecklistItemByID error: %v", err)
}
if _, err := svc.ListChecklistHeaders(context.Background(), airrescuechecklist.ChecklistHeaderFilter{ScopeCode: "TA"}); err != nil {
t.Fatalf("ListChecklistHeaders error: %v", err)
}
if _, err := svc.ListChecklistItemsByChecklistIDs(context.Background(), [][]byte{checklistID}); err != nil {
t.Fatalf("ListChecklistItemsByChecklistIDs error: %v", err)
}
if _, err := svc.ListHEMSBases(context.Background(), airrescuechecklist.HEMSBaseFilter{HEMSBaseID: checklistID}); err != nil {
t.Fatalf("ListHEMSBases error: %v", err)
}
if !repo.getChecklistByIDCalled || !repo.getChecklistItemByIDCalled || !repo.listChecklistHeadersCalled || !repo.listChecklistItemsByIDsCalled || !repo.listHEMSBasesCalled {
t.Fatalf("expected all read wrappers to delegate to repository")
}
}

View File

@@ -0,0 +1,315 @@
package service
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"time"
"wucher/internal/domain/audit"
)
type AuditLogInput struct {
RequestID string
ActorUserID []byte
Layer string
Module string
Action string
Method string
Path string
StatusCode int
Success bool
IP string
UserAgent string
Message string
Metadata map[string]any
}
type AuditLogService struct {
repo audit.Repository
queue chan *audit.AuditLog
ipProtector SecretProtector
}
type AuditLogServiceDependencies struct {
QueueSize int
Workers int
IPEncryptionKey string
}
type auditLogListRepository interface {
List(ctx context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error)
}
func NewAuditLogService(repo audit.Repository, deps AuditLogServiceDependencies) *AuditLogService {
queueSize := deps.QueueSize
workers := deps.Workers
if queueSize <= 0 {
queueSize = 1024
}
if workers <= 0 {
workers = 2
}
key := strings.TrimSpace(deps.IPEncryptionKey)
svc := &AuditLogService{
repo: repo,
queue: make(chan *audit.AuditLog, queueSize),
ipProtector: newIPSecretProtector(key),
}
for i := 0; i < workers; i++ {
go svc.runWorker()
}
return svc
}
func (s *AuditLogService) Log(ctx context.Context, in AuditLogInput) {
if s == nil || s.repo == nil {
return
}
entry := &audit.AuditLog{
RequestID: truncate(strings.TrimSpace(in.RequestID), 64),
ActorUserID: safeUserID(in.ActorUserID),
Layer: truncate(strings.TrimSpace(in.Layer), 32),
Module: truncate(strings.TrimSpace(in.Module), 64),
Action: truncate(strings.TrimSpace(in.Action), 128),
Method: truncate(strings.TrimSpace(in.Method), 12),
Path: truncate(strings.TrimSpace(in.Path), 255),
StatusCode: in.StatusCode,
Success: in.Success,
IP: sanitizeIP(in.IP, s.ipProtector),
UserAgent: truncate(strings.TrimSpace(in.UserAgent), 255),
Message: truncate(strings.TrimSpace(in.Message), 512),
CreatedAt: time.Now().UTC(),
}
if len(in.Metadata) > 0 {
if b, err := json.Marshal(in.Metadata); err == nil {
entry.Metadata = truncateBytes(b, 8192)
}
}
select {
case s.queue <- entry:
default:
log.Printf("audit queue_full layer=%s action=%s", entry.Layer, entry.Action)
}
}
func (s *AuditLogService) List(ctx context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) {
if s == nil || s.repo == nil {
return nil, 0, errors.New("audit service not configured")
}
lister, ok := s.repo.(auditLogListRepository)
if !ok {
return nil, 0, errors.New("audit listing not supported by repository")
}
if limit < 0 {
limit = 0
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
methods, actions = enforceAuditMutationFilters(methods, actions)
rows, total, err := lister.List(
ctx,
strings.TrimSpace(filter),
normalizeAuditMethods(methods),
normalizeAuditTokens(modules),
normalizeAuditTokens(actions),
normalizeAuditSort(sort),
limit,
offset,
)
if err != nil {
return nil, 0, err
}
for i := range rows {
rows[i].IP = decryptStoredIP(rows[i].IP, s.ipProtector)
}
return rows, total, nil
}
func enforceAuditMutationFilters(methods, actions []string) ([]string, []string) {
allowedMethods := []string{"POST", "PUT", "PATCH", "DELETE"}
allowedActions := []string{"create", "update", "delete"}
methods = enforceAllowedMethods(methods, allowedMethods)
actions = enforceAllowedTokens(actions, allowedActions)
return methods, actions
}
func enforceAllowedMethods(values []string, allowed []string) []string {
if len(values) == 0 {
return append([]string(nil), allowed...)
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, value := range allowed {
allowedSet[strings.ToUpper(strings.TrimSpace(value))] = struct{}{}
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
v := strings.ToUpper(strings.TrimSpace(value))
if v == "UPDATE" {
if _, ok := allowedSet["PUT"]; ok {
if _, exists := seen["PUT"]; !exists {
seen["PUT"] = struct{}{}
out = append(out, "PUT")
}
}
if _, ok := allowedSet["PATCH"]; ok {
if _, exists := seen["PATCH"]; !exists {
seen["PATCH"] = struct{}{}
out = append(out, "PATCH")
}
}
continue
}
if _, ok := allowedSet[v]; !ok {
continue
}
if _, exists := seen[v]; exists {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return append([]string(nil), allowed...)
}
return out
}
func enforceAllowedTokens(values []string, allowed []string) []string {
if len(values) == 0 {
return append([]string(nil), allowed...)
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, value := range allowed {
allowedSet[strings.ToLower(strings.TrimSpace(value))] = struct{}{}
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
v := strings.ToLower(strings.TrimSpace(value))
if _, ok := allowedSet[v]; !ok {
continue
}
if _, exists := seen[v]; exists {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return append([]string(nil), allowed...)
}
return out
}
func (s *AuditLogService) runWorker() {
for entry := range s.queue {
if entry == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := s.repo.Create(ctx, entry)
cancel()
if err != nil {
log.Printf("audit persist_failed layer=%s action=%s err=%v", entry.Layer, entry.Action, err)
}
}
}
func safeUserID(id []byte) []byte {
if len(id) != 16 {
return nil
}
dst := make([]byte, 16)
copy(dst, id)
return dst
}
func truncateBytes(v []byte, max int) []byte {
if max <= 0 || len(v) <= max {
return v
}
out := make([]byte, max)
copy(out, v[:max])
return out
}
func normalizeAuditSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortField("created_at"),
sortField("action"),
sortField("module"),
sortField("layer"),
sortField("status_code"),
sortField("success"),
); normalized != "" {
return normalized
}
return "created_at DESC"
}
func normalizeAuditTokens(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
case "", "all", "*":
return nil
default:
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
out = append(out, v)
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeAuditMethods(methods []string) []string {
if len(methods) == 0 {
return nil
}
seen := make(map[string]struct{}, len(methods))
out := make([]string, 0, len(methods))
for _, method := range methods {
v := strings.ToUpper(strings.TrimSpace(method))
switch v {
case "", "ALL", "*":
return nil
case "GET", "POST", "PUT", "PATCH", "DELETE":
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
out = append(out, v)
}
case "UPDATE":
if _, ok := seen["PUT"]; !ok {
seen["PUT"] = struct{}{}
out = append(out, "PUT")
}
if _, ok := seen["PATCH"]; !ok {
seen["PATCH"] = struct{}{}
out = append(out, "PATCH")
}
}
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -0,0 +1,288 @@
package service
import (
"context"
"errors"
"sync"
"testing"
"time"
"wucher/internal/domain/audit"
)
type mockAuditRepo struct {
mu sync.Mutex
entries []*audit.AuditLog
createCalls int
createErr error
listRows []audit.AuditLog
listTotal int64
listErr error
lastFilter string
lastMethods []string
lastModules []string
lastActions []string
lastSort string
lastLimit int
lastOffset int
}
func (m *mockAuditRepo) Create(_ context.Context, entry *audit.AuditLog) error {
m.mu.Lock()
defer m.mu.Unlock()
m.createCalls++
cp := *entry
m.entries = append(m.entries, &cp)
return m.createErr
}
func (m *mockAuditRepo) List(_ context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastFilter = filter
m.lastMethods = append([]string(nil), methods...)
m.lastModules = append([]string(nil), modules...)
m.lastActions = append([]string(nil), actions...)
m.lastSort = sort
m.lastLimit = limit
m.lastOffset = offset
if m.listErr != nil {
return nil, 0, m.listErr
}
out := make([]audit.AuditLog, len(m.listRows))
copy(out, m.listRows)
return out, m.listTotal, nil
}
func TestAuditLogService_LogAsync(t *testing.T) {
repo := &mockAuditRepo{}
svc := NewAuditLogService(repo, AuditLogServiceDependencies{QueueSize: 8, Workers: 1, IPEncryptionKey: "pepper"})
defer close(svc.queue)
svc.Log(context.Background(), AuditLogInput{
RequestID: "req-1",
Layer: "service",
Action: "auth.reset_password.consume",
Success: true,
StatusCode: 200,
IP: "127.0.0.1",
Message: "ok",
Metadata: map[string]any{
"k": "v",
},
})
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) != 1 {
t.Fatalf("expected 1 audit entry, got %d", len(repo.entries))
}
if repo.entries[0].Action != "auth.reset_password.consume" {
t.Fatalf("unexpected action: %s", repo.entries[0].Action)
}
if repo.entries[0].IP == "127.0.0.1" {
t.Fatalf("expected encrypted ip to be persisted")
}
if plain := decryptStoredIP(repo.entries[0].IP, newIPSecretProtector("pepper")); plain != "127.0.0.1" {
t.Fatalf("unexpected decrypted ip: %s", plain)
}
}
func TestAuditLogService_NewAuditLogService_Defaults(t *testing.T) {
repo := &mockAuditRepo{}
svc := NewAuditLogService(repo, AuditLogServiceDependencies{QueueSize: 0, Workers: 0})
defer close(svc.queue)
if cap(svc.queue) != 1024 {
t.Fatalf("expected default queue size 1024, got %d", cap(svc.queue))
}
svc.Log(context.Background(), AuditLogInput{
Layer: "service",
Action: "audit.defaults",
})
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("expected default workers to process queued entry")
}
func TestAuditLogService_Log_NoopAndQueueBehavior(t *testing.T) {
t.Run("no-op when receiver is nil", func(t *testing.T) {
var svc *AuditLogService
svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "noop"})
})
t.Run("no-op when repo is nil", func(t *testing.T) {
svc := &AuditLogService{repo: nil, queue: make(chan *audit.AuditLog, 1)}
svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "noop"})
if len(svc.queue) != 0 {
t.Fatalf("expected no queued entries when repo is nil")
}
})
t.Run("metadata marshal failure does not panic and leaves metadata empty", func(t *testing.T) {
svc := &AuditLogService{
repo: &mockAuditRepo{},
queue: make(chan *audit.AuditLog, 1),
}
svc.Log(context.Background(), AuditLogInput{
Layer: "service",
Action: "audit.bad_metadata",
Metadata: map[string]any{"bad": func() {}},
})
if len(svc.queue) != 1 {
t.Fatalf("expected one queued entry")
}
entry := <-svc.queue
if len(entry.Metadata) != 0 {
t.Fatalf("expected metadata to be empty when marshal fails")
}
})
t.Run("queue full branch drops extra entry", func(t *testing.T) {
svc := &AuditLogService{
repo: &mockAuditRepo{},
queue: make(chan *audit.AuditLog, 1),
}
svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "first"})
svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "second"})
if len(svc.queue) != 1 {
t.Fatalf("expected queue to contain only first entry, got %d", len(svc.queue))
}
entry := <-svc.queue
if entry.Action != "first" {
t.Fatalf("expected first entry to be retained, got %q", entry.Action)
}
})
}
func TestAuditLogService_runWorker_NilEntryAndPersistError(t *testing.T) {
repo := &mockAuditRepo{createErr: errors.New("persist failed")}
svc := &AuditLogService{
repo: repo,
queue: make(chan *audit.AuditLog, 2),
}
done := make(chan struct{})
go func() {
svc.runWorker()
close(done)
}()
svc.queue <- nil
svc.queue <- &audit.AuditLog{
Layer: "service",
Action: "audit.persist_error",
}
close(svc.queue)
select {
case <-done:
case <-time.After(time.Second):
t.Fatalf("worker did not stop after queue close")
}
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.createCalls != 1 {
t.Fatalf("expected exactly one repo.Create call for non-nil entry, got %d", repo.createCalls)
}
if len(repo.entries) != 1 {
t.Fatalf("expected one persisted entry attempt, got %d", len(repo.entries))
}
if repo.entries[0].Action != "audit.persist_error" {
t.Fatalf("unexpected persisted action: %s", repo.entries[0].Action)
}
}
func TestTruncateBytes(t *testing.T) {
original := []byte("abcdef")
t.Run("max less than or equal zero returns original bytes", func(t *testing.T) {
got := truncateBytes(original, 0)
if string(got) != string(original) {
t.Fatalf("expected %q, got %q", string(original), string(got))
}
})
t.Run("len less than or equal max returns original bytes", func(t *testing.T) {
got := truncateBytes(original, len(original))
if string(got) != string(original) {
t.Fatalf("expected %q, got %q", string(original), string(got))
}
})
t.Run("len greater than max returns truncated bytes", func(t *testing.T) {
got := truncateBytes(original, 3)
if string(got) != "abc" {
t.Fatalf("expected %q, got %q", "abc", string(got))
}
if len(got) != 3 {
t.Fatalf("expected len 3, got %d", len(got))
}
})
}
func TestAuditLogService_List(t *testing.T) {
encIP := sanitizeIP("127.0.0.1", newIPSecretProtector("pepper"))
repo := &mockAuditRepo{
listRows: []audit.AuditLog{
{Layer: "api", Action: "http.request", IP: encIP},
},
listTotal: 1,
}
svc := NewAuditLogService(repo, AuditLogServiceDependencies{QueueSize: 8, Workers: 1, IPEncryptionKey: "pepper"})
defer close(svc.queue)
rows, total, err := svc.List(context.Background(), "http", []string{"GET", "update"}, []string{"helicopters"}, []string{"list"}, "-created_at", 20, 0)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("unexpected list result total=%d len=%d", total, len(rows))
}
if rows[0].IP != "127.0.0.1" {
t.Fatalf("expected decrypted ip in list response, got %q", rows[0].IP)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.lastSort != "created_at DESC" {
t.Fatalf("expected normalized sort created_at DESC, got %q", repo.lastSort)
}
if repo.lastFilter != "http" {
t.Fatalf("expected filter propagated, got %q", repo.lastFilter)
}
if len(repo.lastMethods) != 2 || repo.lastMethods[0] != "PUT" || repo.lastMethods[1] != "PATCH" {
t.Fatalf("expected normalized methods [PUT PATCH], got %#v", repo.lastMethods)
}
if len(repo.lastModules) != 1 || repo.lastModules[0] != "helicopters" {
t.Fatalf("expected modules [helicopters], got %#v", repo.lastModules)
}
if len(repo.lastActions) != 3 || repo.lastActions[0] != "create" || repo.lastActions[1] != "update" || repo.lastActions[2] != "delete" {
t.Fatalf("expected actions [create update delete], got %#v", repo.lastActions)
}
}

View File

@@ -0,0 +1,559 @@
package service
import (
"context"
"errors"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"time"
filemanager "wucher/internal/domain/file_manager"
mastersettings "wucher/internal/domain/master_settings"
"wucher/internal/queue"
)
const (
defaultEmailBrandingCompanyName = "Wucher"
authEmailLogoCID = "branding-email-logo"
authEmailLogoFilename = "email-logo.png"
authEmailLogoFetchTimeout = 10 * time.Second
authEmailLogoMaxBytes = 5 << 20
authEmailLogoPresignTTL = time.Minute
)
type AuthEmailBranding struct {
CompanyName string
LogoContentID string
LogoAttachment *queue.EmailAttachment
}
type BuiltAuthEmail struct {
Subject string
TextBody string
HTMLBody string
Attachments []queue.EmailAttachment
}
type AuthEmailBrandingResolver interface {
ResolveEmailBranding(ctx context.Context) AuthEmailBranding
}
type authEmailBrandingResolver struct {
masterSvc mastersettings.Service
fileSvc filemanager.Service
storage filemanager.ObjectStorage
httpClient *http.Client
}
func NewAuthEmailBrandingResolver(masterSvc mastersettings.Service, fileSvc filemanager.Service, storage filemanager.ObjectStorage) AuthEmailBrandingResolver {
return &authEmailBrandingResolver{
masterSvc: masterSvc,
fileSvc: fileSvc,
storage: storage,
httpClient: &http.Client{
Timeout: authEmailLogoFetchTimeout,
},
}
}
func (r *authEmailBrandingResolver) ResolveEmailBranding(ctx context.Context) AuthEmailBranding {
branding := AuthEmailBranding{
CompanyName: defaultEmailBrandingCompanyName,
}
if r == nil || r.masterSvc == nil {
return branding
}
rows, _, err := r.masterSvc.List(ctx, "", "-updated_at", 1, 0)
if err != nil || len(rows) == 0 {
return branding
}
row := rows[0]
if companyName := strings.TrimSpace(row.CompanyName); companyName != "" {
branding.CompanyName = companyName
}
if len(row.LogoFileID) == 0 {
return branding
}
attachment, err := r.resolveInlineLogoAttachment(ctx, row.LogoFileID)
if err != nil || attachment == nil {
return branding
}
branding.LogoContentID = attachment.ContentID
branding.LogoAttachment = attachment
return branding
}
func (r *authEmailBrandingResolver) resolveInlineLogoAttachment(ctx context.Context, fileID []byte) (*queue.EmailAttachment, error) {
if r == nil || r.fileSvc == nil {
return nil, errors.New("file manager service is not configured")
}
if r.storage == nil {
return nil, errors.New("object storage is not configured")
}
fileRow, err := r.fileSvc.GetFileByID(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("get managed branding file: %w", err)
}
if fileRow == nil {
return nil, errors.New("managed branding file not found")
}
if !strings.EqualFold(strings.TrimSpace(fileRow.Status), filemanager.FileStatusReady) {
return nil, errors.New("managed branding file is not ready")
}
objectKey, declaredContentType, err := selectAuthEmailLogoVariant(fileRow)
if err != nil {
return nil, err
}
presigned, err := r.storage.PresignGetObject(ctx, objectKey, authEmailLogoPresignTTL)
if err != nil {
return nil, fmt.Errorf("presign managed branding object: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, presigned, nil)
if err != nil {
return nil, fmt.Errorf("build object request: %w", err)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download logo object: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("object storage returned status %d", resp.StatusCode)
}
limited := io.LimitReader(resp.Body, authEmailLogoMaxBytes+1)
body, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read logo object body: %w", err)
}
if len(body) == 0 {
return nil, errors.New("logo object body is empty")
}
if len(body) > authEmailLogoMaxBytes {
return nil, errors.New("logo object exceeds max allowed size")
}
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = declaredContentType
}
if !isAuthEmailSafeImageContentType(contentType) {
return nil, errors.New("managed branding asset is not email-safe")
}
return &queue.EmailAttachment{
Filename: authEmailLogoFilename,
ContentType: normalizeAuthEmailImageContentType(contentType),
ContentID: authEmailLogoCID,
Content: body,
Inline: true,
}, nil
}
func selectAuthEmailLogoVariant(fileRow *filemanager.File) (string, string, error) {
if fileRow == nil {
return "", "", errors.New("managed branding file is missing")
}
if fileRow.ThumbnailObjectKey != nil {
thumbKey := strings.TrimSpace(*fileRow.ThumbnailObjectKey)
thumbType := strings.TrimSpace(authEmailPtrStringValue(fileRow.ThumbnailMimeType))
if thumbKey != "" && isAuthEmailSafeImageContentType(thumbType) {
return thumbKey, thumbType, nil
}
}
originalKey := strings.TrimSpace(fileRow.ObjectKey)
originalType := strings.TrimSpace(fileRow.MimeType)
if originalKey != "" && isAuthEmailSafeImageContentType(originalType) {
return originalKey, originalType, nil
}
return "", "", errors.New("managed branding file has no email-safe variant")
}
func isAuthEmailSafeImageContentType(contentType string) bool {
contentType = normalizeAuthEmailImageContentType(contentType)
switch contentType {
case "image/png", "image/jpeg", "image/gif":
return true
default:
return false
}
}
func normalizeAuthEmailImageContentType(contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType))
if idx := strings.Index(contentType, ";"); idx >= 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
if contentType == "image/jpg" {
return "image/jpeg"
}
return contentType
}
func authEmailPtrStringValue(v *string) string {
if v == nil {
return ""
}
return *v
}
type AuthEmailTemplateContent struct {
Subject string
Greeting string
Title string
Message []string
Code string
CTALabel string
CTAURL string
HelpText string
FooterText string
}
func buildAuthEmailTemplate(ctx context.Context, resolver AuthEmailBrandingResolver, content AuthEmailTemplateContent) BuiltAuthEmail {
subject := strings.TrimSpace(content.Subject)
greeting := strings.TrimSpace(content.Greeting)
title := strings.TrimSpace(content.Title)
ctaLabel := strings.TrimSpace(content.CTALabel)
ctaURL := strings.TrimSpace(content.CTAURL)
helpText := strings.TrimSpace(content.HelpText)
footerText := strings.TrimSpace(content.FooterText)
code := strings.TrimSpace(content.Code)
messageParts := make([]string, 0, len(content.Message))
for i := range content.Message {
part := strings.TrimSpace(content.Message[i])
if part == "" {
continue
}
messageParts = append(messageParts, part)
}
textLines := make([]string, 0, len(messageParts)+10)
if greeting != "" {
textLines = append(textLines, greeting, "")
}
textLines = append(textLines, title, "")
textLines = append(textLines, messageParts...)
if code != "" {
textLines = append(textLines, code)
}
if ctaURL != "" {
textLines = append(textLines, "", ctaLabel+":", ctaURL)
}
if helpText != "" {
textLines = append(textLines, "", helpText)
}
if footerText != "" {
textLines = append(textLines, "", footerText)
}
branding := AuthEmailBranding{CompanyName: defaultEmailBrandingCompanyName}
if resolver != nil {
branding = resolver.ResolveEmailBranding(ctx)
if strings.TrimSpace(branding.CompanyName) == "" {
branding.CompanyName = defaultEmailBrandingCompanyName
}
}
var attachments []queue.EmailAttachment
if branding.LogoAttachment != nil {
attachments = append(attachments, cloneAuthEmailAttachment(*branding.LogoAttachment))
}
return BuiltAuthEmail{
Subject: subject,
TextBody: strings.Join(textLines, "\n"),
HTMLBody: renderAuthEmailHTML(branding, AuthEmailTemplateContent{Subject: subject, Greeting: greeting, Title: title, Message: messageParts, Code: code, CTALabel: ctaLabel, CTAURL: ctaURL, HelpText: helpText, FooterText: footerText}),
Attachments: attachments,
}
}
func cloneAuthEmailAttachment(attachment queue.EmailAttachment) queue.EmailAttachment {
attachment.Content = append([]byte(nil), attachment.Content...)
return attachment
}
// emailFontStack is the cross-client system font stack used in all email cells.
const emailFontStack = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif`
// emailCell writes an opening <tr><td> with the given inline style.
func emailCell(b *strings.Builder, style string) {
b.WriteString(`<tr><td style="`)
b.WriteString(style)
b.WriteString(`">`)
}
func emailCellClose(b *strings.Builder) {
b.WriteString(`</td></tr>`)
}
// emailSpacer writes a zero-content spacer row.
func emailSpacer(b *strings.Builder, height string) {
b.WriteString(`<tr><td style="height:`)
b.WriteString(height)
b.WriteString(`;font-size:0;line-height:0;">&nbsp;</td></tr>`)
}
// emailDivider writes a 1px horizontal rule as a nested table (Outlook-safe).
func emailDivider(b *strings.Builder) {
b.WriteString(`<tr><td style="padding:0 32px;">`)
b.WriteString(`<table role="presentation" width="100%" cellspacing="0" cellpadding="0">`)
b.WriteString(`<tr><td style="height:1px;background-color:#e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr>`)
b.WriteString(`</table></td></tr>`)
}
func renderAuthEmailHTML(branding AuthEmailBranding, content AuthEmailTemplateContent) string {
subject := html.EscapeString(strings.TrimSpace(content.Subject))
greeting := html.EscapeString(strings.TrimSpace(content.Greeting))
title := html.EscapeString(strings.TrimSpace(content.Title))
ctaLabel := html.EscapeString(strings.TrimSpace(content.CTALabel))
ctaURL := html.EscapeString(strings.TrimSpace(content.CTAURL))
helpText := html.EscapeString(strings.TrimSpace(content.HelpText))
footerText := html.EscapeString(strings.TrimSpace(content.FooterText))
code := html.EscapeString(strings.TrimSpace(content.Code))
companyName := html.EscapeString(strings.TrimSpace(branding.CompanyName))
logoContentID := html.EscapeString(strings.TrimSpace(branding.LogoContentID))
var b strings.Builder
b.WriteString(`<!DOCTYPE html><html lang="en">`)
b.WriteString(`<head>`)
b.WriteString(`<meta charset="UTF-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1.0">`)
b.WriteString(`<meta http-equiv="X-UA-Compatible" content="IE=edge">`)
b.WriteString(`<title>`)
b.WriteString(subject)
b.WriteString(`</title>`)
b.WriteString(`<style>`)
b.WriteString(`@media only screen and (max-width:620px){`)
b.WriteString(`.em-cell{padding-left:20px!important;padding-right:20px!important;}`)
b.WriteString(`.em-cta a{display:block!important;text-align:center!important;}`)
b.WriteString(`}`)
b.WriteString(`</style>`)
b.WriteString(`</head>`)
b.WriteString(`<body style="margin:0;padding:0;background-color:#ffffff;`)
b.WriteString(`-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;">`)
b.WriteString(`<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;color:#ffffff;">`)
b.WriteString(subject)
b.WriteString(strings.Repeat(" &zwnj;&nbsp;", 50))
b.WriteString(`</div>`)
b.WriteString(`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" bgcolor="#ffffff">`)
b.WriteString(`<tr><td align="center" style="padding:0;">`)
b.WriteString(`<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;">`)
b.WriteString(`<tr><td style="height:4px;background-color:#18181b;font-size:0;line-height:0;">&nbsp;</td></tr>`)
b.WriteString(`</table>`)
b.WriteString(`<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;">`)
emailSpacer(&b, "44px")
b.WriteString(`<tr><td class="em-cell" align="left" style="padding:0 32px 36px;">`)
if logoContentID != "" {
b.WriteString(`<img src="cid:`)
b.WriteString(logoContentID)
b.WriteString(`" alt="`)
b.WriteString(companyName)
b.WriteString(`" height="36" style="display:block;height:36px;width:auto;max-width:200px;border:0;outline:none;text-decoration:none;">`)
} else {
b.WriteString(`<span style="font-size:16px;font-weight:700;color:#18181b;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;letter-spacing:-0.3px;">`)
b.WriteString(companyName)
b.WriteString(`</span>`)
}
b.WriteString(`</td></tr>`)
emailDivider(&b)
emailSpacer(&b, "40px")
if greeting != "" {
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 6px;font-size:14px;line-height:1.6;color:#71717a;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(greeting)
b.WriteString(`</td></tr>`)
}
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 20px;font-size:26px;line-height:1.25;font-weight:700;`)
b.WriteString(`letter-spacing:-0.5px;color:#18181b;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(title)
b.WriteString(`</td></tr>`)
for i := range content.Message {
msg := html.EscapeString(strings.TrimSpace(content.Message[i]))
if msg == "" {
continue
}
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 12px;font-size:15px;line-height:1.75;color:#3f3f46;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(msg)
b.WriteString(`</td></tr>`)
}
if code != "" {
b.WriteString(`<tr><td class="em-cell" style="padding:4px 32px 16px;">`)
b.WriteString(`<span style="display:inline-block;background-color:#f4f4f5;border:1px solid #e4e4e7;`)
b.WriteString(`border-radius:10px;padding:12px 16px;font-size:28px;line-height:1;letter-spacing:6px;`)
b.WriteString(`color:#111827;font-weight:700;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(code)
b.WriteString(`</span></td></tr>`)
}
if ctaURL != "" && ctaLabel != "" {
emailSpacer(&b, "20px")
b.WriteString(`<tr><td class="em-cell em-cta" align="left" style="padding:0 32px 16px;">`)
b.WriteString(`<!--[if mso]>`)
b.WriteString(`<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"`)
b.WriteString(` href="`)
b.WriteString(ctaURL)
b.WriteString(`" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="18%" stroke="f" fillcolor="#18181b">`)
b.WriteString(`<w:anchorlock/>`)
b.WriteString(`<center style="color:#ffffff;font-family:sans-serif;font-size:14px;font-weight:700;">`)
b.WriteString(ctaLabel)
b.WriteString(`</center></v:roundrect><![endif]-->`)
b.WriteString(`<!--[if !mso]><!-->`)
b.WriteString(`<a href="`)
b.WriteString(ctaURL)
b.WriteString(`" target="_blank" style="display:inline-block;background-color:#18181b;color:#ffffff;`)
b.WriteString(`font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;font-size:14px;font-weight:600;letter-spacing:0.1px;line-height:1;`)
b.WriteString(`text-decoration:none;padding:13px 26px;border-radius:8px;`)
b.WriteString(`-webkit-text-size-adjust:none;">`)
b.WriteString(ctaLabel)
b.WriteString(`</a>`)
b.WriteString(`<!--<![endif]-->`)
b.WriteString(`</td></tr>`)
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 4px;font-size:12px;line-height:1.6;color:#a1a1aa;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">Or copy and paste this link:</td></tr>`)
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 36px;font-size:12px;line-height:1.6;color:#71717a;`)
b.WriteString(`word-break:break-all;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(ctaURL)
b.WriteString(`</td></tr>`)
}
emailDivider(&b)
emailSpacer(&b, "24px")
if helpText != "" {
b.WriteString(`<tr><td class="em-cell" style="padding:0 32px 32px;font-size:13px;line-height:1.7;color:#71717a;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(helpText)
b.WriteString(`</td></tr>`)
}
b.WriteString(`<tr><td class="em-cell" style="padding:20px 32px 0;border-top:1px solid #e4e4e7;">`)
b.WriteString(`<span style="font-size:12px;line-height:1.7;color:#a1a1aa;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(companyName)
b.WriteString(`</span>`)
if footerText != "" {
b.WriteString(`<br><span style="font-size:12px;line-height:1.7;color:#a1a1aa;font-family:`)
b.WriteString(emailFontStack)
b.WriteString(`;">`)
b.WriteString(footerText)
b.WriteString(`</span>`)
}
b.WriteString(`</td></tr>`)
emailSpacer(&b, "48px")
b.WriteString(`</table>`)
b.WriteString(`</td></tr>`)
b.WriteString(`</table>`)
b.WriteString(`</body></html>`)
return b.String()
}
func formatEmailTTLMinutes(ttl time.Duration, fallback int) int {
minutes := int(ttl.Minutes())
if minutes < 1 {
return fallback
}
return minutes
}
func buildPasswordResetEmail(ctx context.Context, resolver AuthEmailBrandingResolver, userName string, link string, ttl time.Duration) BuiltAuthEmail {
minutes := formatEmailTTLMinutes(ttl, 20)
greeting := ""
if name := strings.TrimSpace(userName); name != "" {
greeting = "Hi " + name + ","
}
return buildAuthEmailTemplate(ctx, resolver, AuthEmailTemplateContent{
Subject: "Reset your password",
Greeting: greeting,
Title: "Reset your password",
Message: []string{
"We received a request to reset your password.",
"Click the button below to choose a new password. This link expires in " + strconv.Itoa(minutes) + " minutes and can only be used once.",
},
CTALabel: "Reset Password",
CTAURL: link,
HelpText: "If you did not request this, you can safely ignore this email. Your password will remain unchanged.",
FooterText: "For your security, never share this link with anyone.",
})
}
func buildSecurityPINResetEmail(ctx context.Context, resolver AuthEmailBrandingResolver, userName string, link string, ttl time.Duration) BuiltAuthEmail {
minutes := formatEmailTTLMinutes(ttl, 20)
greeting := ""
if name := strings.TrimSpace(userName); name != "" {
greeting = "Hi " + name + ","
}
return buildAuthEmailTemplate(ctx, resolver, AuthEmailTemplateContent{
Subject: "Reset your security PIN",
Greeting: greeting,
Title: "Reset your security PIN",
Message: []string{
"We received a request to reset your security PIN.",
"Click the button below to set a new 6-digit PIN. This link expires in " + strconv.Itoa(minutes) + " minutes and can only be used once.",
},
CTALabel: "Reset Security PIN",
CTAURL: link,
HelpText: "If you did not request this, you can safely ignore this email. Your PIN will remain unchanged.",
FooterText: "For your security, never share this link or your PIN with anyone.",
})
}
func buildLoginOTPEmail(ctx context.Context, resolver AuthEmailBrandingResolver, userName, otpCode string, ttl time.Duration) BuiltAuthEmail {
minutes := formatEmailTTLMinutes(ttl, 10)
greeting := ""
if name := strings.TrimSpace(userName); name != "" {
greeting = "Hi " + name + ","
}
return buildAuthEmailTemplate(ctx, resolver, AuthEmailTemplateContent{
Subject: "Your login verification code",
Greeting: greeting,
Title: "Verify your login",
Message: []string{
"Use this one-time code to complete your login:",
"This code expires in " + strconv.Itoa(minutes) + " minutes.",
},
Code: strings.TrimSpace(otpCode),
HelpText: "If this wasn't you, change your password immediately and contact support.",
FooterText: "For your security, never share this code with anyone.",
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
package service
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"wucher/internal/domain/auth"
"wucher/internal/shared/pkg/uuidv7"
)
type authSessionRecord struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
CurrentJTI string `json:"current_jti"`
RefreshExpAt time.Time `json:"refresh_exp_at"`
AuthProvider string `json:"auth_provider,omitempty"`
MicrosoftSID string `json:"microsoft_sid,omitempty"`
DeviceType string `json:"device_type,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
DeviceName string `json:"device_name"`
IPAddress string `json:"ip_address,omitempty"`
LongLived bool `json:"long_lived"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
}
type UserSession struct {
ID string `json:"id"`
DeviceName string `json:"device_name"`
UserAgent string `json:"user_agent,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
ActiveNow bool `json:"active_now"`
}
const (
refreshReplayTTL = 5 * time.Second
refreshLockTTL = 15 * time.Second
refreshReplayWaitTimeout = 5 * time.Second
refreshReplayPollInterval = 50 * time.Millisecond
)
type refreshReplayPayload struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
RefreshTTLSecond int64 `json:"refresh_ttl_second"`
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
}
func (p refreshReplayPayload) toTokenPair() TokenPair {
return TokenPair{
AccessToken: p.AccessToken,
RefreshToken: p.RefreshToken,
RefreshTTL: time.Duration(p.RefreshTTLSecond) * time.Second,
SessionID: p.SessionID,
}
}
func newRefreshReplayPayload(pair TokenPair, userID, sessionID string) refreshReplayPayload {
refreshTTL := pair.RefreshTTL
if refreshTTL <= 0 {
refreshTTL = time.Second
}
return refreshReplayPayload{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
RefreshTTLSecond: int64(refreshTTL.Seconds()),
SessionID: strings.TrimSpace(sessionID),
UserID: strings.TrimSpace(userID),
}
}
func (s *AuthService) IssueTokensWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) {
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", "", "")
}
func (s *AuthService) IssueTokensAfterTOTPWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) {
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", "", "")
}
func (s *AuthService) IssueTokensWithClientAndDeviceType(
ctx context.Context,
user *auth.User,
userAgent, ipAddress, deviceTypeHint string,
) (TokenPair, error) {
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", deviceTypeHint, "")
}
func (s *AuthService) IssueTokensAfterTOTPWithClientAndDeviceType(
ctx context.Context,
user *auth.User,
userAgent, ipAddress, deviceTypeHint string,
) (TokenPair, error) {
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", deviceTypeHint, "")
}
func (s *AuthService) IssueTokensWithClientForProvider(
ctx context.Context,
user *auth.User,
userAgent, ipAddress, authProvider string,
) (TokenPair, error) {
refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", "")
}
func (s *AuthService) IssueTokensWithClientForProviderAndDeviceType(
ctx context.Context,
user *auth.User,
userAgent, ipAddress, authProvider, deviceTypeHint string,
) (TokenPair, error) {
refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, deviceTypeHint, "")
}
func (s *AuthService) IssueTokensWithClientForProviderAndMicrosoftSID(
ctx context.Context,
user *auth.User,
userAgent, ipAddress, authProvider, microsoftSID string,
) (TokenPair, error) {
refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", microsoftSID)
}
// providerRefreshTTL resolves the refresh-token lifetime for a login session.
// For Microsoft/SSO sessions it follows the lifetime of the stored Microsoft
// refresh token so the app session stays valid for exactly as long as Microsoft
// will keep refreshing it (validated on every /auth/refresh). When Microsoft
// does not report an expiry it falls back to the configured SSO default. Other
// providers keep the standard refresh TTL.
func (s *AuthService) providerRefreshTTL(ctx context.Context, userID []byte, authProvider string) time.Duration {
if !strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) {
return s.cfg.JWTRefreshTTL
}
fallback := s.cfg.JWTSSORefreshTTL
if fallback <= 0 {
fallback = s.cfg.JWTRefreshTTL
}
repo, ok := s.repo.(microsoftOAuthTokenStore)
if !ok {
return fallback
}
record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
if err != nil || record == nil || record.RefreshTokenExpiresAt == nil {
return fallback
}
remaining := time.Until(*record.RefreshTokenExpiresAt)
if remaining <= time.Minute {
return fallback
}
return remaining
}
func (s *AuthService) issueLoginTokensWithRefreshTTLAndClient(
ctx context.Context,
user *auth.User,
refreshTTL time.Duration,
longLived bool,
userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string,
) (TokenPair, error) {
if user == nil || len(user.ID) != 16 {
return TokenPair{}, ErrInvalidToken
}
if !user.IsActive {
return TokenPair{}, ErrInvalidToken
}
return s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, "", userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID)
}
func (s *AuthService) issueTokensWithRefreshTTLAndClient(
ctx context.Context,
user *auth.User,
refreshTTL time.Duration,
longLived bool,
sessionID, userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string,
) (TokenPair, error) {
if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" {
return TokenPair{}, errors.New("jwt secrets not configured")
}
if refreshTTL <= 0 {
refreshTTL = s.cfg.JWTRefreshTTL
}
now := time.Now().UTC()
newLogin := strings.TrimSpace(sessionID) == ""
if newLogin {
sessionID, _ = uuidv7.BytesToString(uuidv7.MustBytes())
}
existingSessionIDs := []string{}
if s.tokens != nil && newLogin {
if ids, err := s.loadUserSessionIDs(ctx, user.ID); err == nil {
existingSessionIDs = ids
}
}
sessionVersion, _ := s.sessionVersion(ctx, user.ID)
accessClaims := jwt.MapClaims{
"sub": bytesToString(user.ID),
"iat": now.Unix(),
"exp": now.Add(s.cfg.JWTAccessTTL).Unix(),
"type": "access",
"sv": sessionVersion,
"sid": sessionID,
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(s.cfg.JWTAccessSecret))
if err != nil {
return TokenPair{}, err
}
jti, err := randomToken(32)
if err != nil {
return TokenPair{}, err
}
refreshClaims := jwt.MapClaims{
"sub": bytesToString(user.ID),
"iat": now.Unix(),
"exp": now.Add(refreshTTL).Unix(),
"jti": jti,
"type": "refresh",
"sv": sessionVersion,
"sid": sessionID,
}
if longLived {
refreshClaims["ll"] = true
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(s.cfg.JWTRefreshSecret))
if err != nil {
return TokenPair{}, err
}
if s.tokens != nil {
_ = s.tokens.Set(ctx, refreshKey(jti), []byte(sessionID), refreshTTL)
if strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) && strings.TrimSpace(microsoftSID) != "" {
_ = s.tokens.Set(ctx, microsoftSessionKey(microsoftSID), []byte(sessionID), refreshTTL)
}
createdAt := now
record, _ := s.loadSessionRecord(ctx, sessionID)
if record != nil && record.UserID == bytesToString(user.ID) {
createdAt = record.CreatedAt
if strings.TrimSpace(authProvider) == "" {
authProvider = strings.TrimSpace(record.AuthProvider)
}
if strings.TrimSpace(userAgent) == "" {
userAgent = record.UserAgent
}
if strings.TrimSpace(ipAddress) == "" {
ipAddress = record.IPAddress
}
}
session := &authSessionRecord{
SessionID: sessionID,
UserID: bytesToString(user.ID),
CurrentJTI: jti,
RefreshExpAt: now.Add(refreshTTL),
AuthProvider: strings.TrimSpace(authProvider),
MicrosoftSID: strings.TrimSpace(microsoftSID),
DeviceType: classifyDeviceType(strings.TrimSpace(userAgent), deviceTypeHint),
UserAgent: strings.TrimSpace(userAgent),
DeviceName: deriveDeviceName(strings.TrimSpace(userAgent)),
IPAddress: sanitizeIP(strings.TrimSpace(ipAddress), s.ipProtector),
LongLived: longLived,
CreatedAt: createdAt,
LastActiveAt: now,
}
if err := s.saveSessionRecord(ctx, session, refreshTTL); err == nil {
if newLogin {
_ = s.enforceUserDeviceSlots(ctx, user.ID, existingSessionIDs, session)
} else {
_ = s.addUserSessionID(ctx, user.ID, sessionID)
}
}
}
return TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
RefreshTTL: refreshTTL,
SessionID: sessionID,
}, nil
}
func (s *AuthService) RefreshTokensWithClient(ctx context.Context, refreshToken, userAgent, ipAddress string) (TokenPair, error) {
return s.RefreshTokensWithClientAndDeviceType(ctx, refreshToken, userAgent, ipAddress, "")
}
func (s *AuthService) RefreshTokensWithClientAndDeviceType(
ctx context.Context,
refreshToken, userAgent, ipAddress, deviceTypeHint string,
) (TokenPair, error) {
if refreshToken == "" {
return TokenPair{}, ErrInvalidToken
}
parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTRefreshSecret), nil
})
if err != nil || !parsed.Valid {
return TokenPair{}, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok || claims["type"] != "refresh" {
return TokenPair{}, ErrInvalidToken
}
jti, _ := claims["jti"].(string)
sub, _ := claims["sub"].(string)
sid, _ := claims["sid"].(string)
if jti == "" || sub == "" || sid == "" {
return TokenPair{}, ErrInvalidToken
}
userID, err := uuidv7.ParseString(sub)
if err != nil {
return TokenPair{}, ErrInvalidToken
}
if s.tokens == nil {
return TokenPair{}, ErrInvalidToken
}
if !s.validSessionVersion(ctx, userID, claims["sv"]) {
return TokenPair{}, ErrInvalidToken
}
if !s.isSessionActive(ctx, userID, sid) {
return TokenPair{}, ErrInvalidToken
}
if replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sid); err != nil {
return TokenPair{}, ErrInvalidToken
} else if ok {
return replay, nil
}
acquired := false
acquired, err = s.tokens.SetIfNotExists(ctx, refreshLockKey(sid), []byte(jti), refreshLockTTL)
if err != nil {
return TokenPair{}, ErrInvalidToken
}
if !acquired {
replay, ok, waitErr := s.waitForRefreshReplay(ctx, jti, userID, sid)
if waitErr != nil {
return TokenPair{}, ErrInvalidToken
}
if ok {
return replay, nil
}
return TokenPair{}, ErrInvalidToken
}
defer func() {
_ = s.tokens.Delete(ctx, refreshLockKey(sid))
}()
var record *authSessionRecord
var raw []byte
raw, err = s.tokens.Get(ctx, refreshKey(jti))
if err != nil || raw == nil || !subtleConstantTimeCompare(raw, []byte(sid)) {
return TokenPair{}, ErrInvalidToken
}
record, err = s.loadSessionRecord(ctx, sid)
if err != nil || record == nil {
return TokenPair{}, ErrInvalidToken
}
if record.UserID != bytesToString(userID) || record.CurrentJTI != jti {
return TokenPair{}, ErrInvalidToken
}
if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil {
// Only revoke when Microsoft says the grant is permanently invalid
// (invalid_grant/interaction_required/consent_required, or the
// stored refresh token is missing). Transient failures — network
// errors, 5xx, throttling, request timeouts, or an open circuit
// breaker — must NOT log the user out: keep the app session alive
// and retry Microsoft on the next refresh cycle.
if errors.Is(err, ErrMicrosoftReauthRequired) {
_ = s.RevokeUserSession(ctx, userID, sid)
return TokenPair{}, ErrInvalidToken
}
}
}
if s.isSessionIdleExpired(record) {
// SSO sessions may continue when Microsoft token refresh is still valid.
if !s.canRecoverSSOSessionOnIdle(ctx, userID, record) {
_ = s.RevokeUserSession(ctx, userID, sid)
return TokenPair{}, ErrInvalidToken
}
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil || user == nil || !user.IsActive {
return TokenPair{}, ErrInvalidToken
}
longLived := boolClaim(claims["ll"])
refreshTTL := s.cfg.JWTRefreshTTL
if longLived {
refreshTTL = s.cfg.JWTRefreshTOTPTTL
} else if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
// Keep the session sliding with the Microsoft refresh token lifetime.
refreshTTL = s.providerRefreshTTL(ctx, userID, record.AuthProvider)
}
pair, err := s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, sid, userAgent, ipAddress, "", deviceTypeHint, "")
if err != nil {
return TokenPair{}, err
}
if s.tokens != nil {
replay := newRefreshReplayPayload(pair, bytesToString(userID), sid)
if payload, marshalErr := json.Marshal(replay); marshalErr == nil {
_ = s.tokens.Set(ctx, refreshReplayKey(jti), payload, refreshReplayTTL)
}
}
return pair, nil
}
func (s *AuthService) loadRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) {
if s.tokens == nil {
return TokenPair{}, false, nil
}
raw, err := s.tokens.Get(ctx, refreshReplayKey(jti))
if err != nil || raw == nil {
return TokenPair{}, false, err
}
var payload refreshReplayPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return TokenPair{}, false, err
}
if strings.TrimSpace(payload.SessionID) != strings.TrimSpace(sessionID) {
return TokenPair{}, false, nil
}
if strings.TrimSpace(payload.UserID) != bytesToString(userID) {
return TokenPair{}, false, nil
}
if !s.isSessionActive(ctx, userID, sessionID) {
return TokenPair{}, false, nil
}
return payload.toTokenPair(), true, nil
}
func (s *AuthService) waitForRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) {
deadline := time.Now().UTC().Add(refreshReplayWaitTimeout)
for {
replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sessionID)
if err != nil {
return TokenPair{}, false, err
}
if ok {
return replay, true, nil
}
if time.Now().UTC().After(deadline) {
return TokenPair{}, false, nil
}
select {
case <-ctx.Done():
return TokenPair{}, false, ctx.Err()
case <-time.After(refreshReplayPollInterval):
}
}
}
func (s *AuthService) revokeRefreshTokenWithSession(ctx context.Context, refreshToken string) error {
if refreshToken == "" {
return nil
}
parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTRefreshSecret), nil
})
if err != nil || !parsed.Valid {
return nil
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil
}
jti, _ := claims["jti"].(string)
sid, _ := claims["sid"].(string)
sub, _ := claims["sub"].(string)
if sid != "" && sub != "" {
if userID, err := uuidv7.ParseString(sub); err == nil {
_ = s.RevokeUserSession(ctx, userID, sid)
return nil
}
}
if jti != "" && s.tokens != nil {
_ = s.tokens.Delete(ctx, refreshKey(jti))
}
return nil
}
func (s *AuthService) ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error) {
_, sid, err := s.parseAccessTokenWithSession(ctx, accessToken)
if err != nil {
return "", err
}
return sid, nil
}
func (s *AuthService) GetSessionAuthContextFromAccessToken(ctx context.Context, accessToken string) (string, string, error) {
_, sid, err := s.parseAccessTokenWithSession(ctx, accessToken)
if err != nil {
return "", "", err
}
if s.tokens == nil {
return "email", "email", nil
}
record, err := s.loadSessionRecord(ctx, sid)
if err != nil || record == nil {
return "", "", ErrInvalidToken
}
provider := strings.ToLower(strings.TrimSpace(record.AuthProvider))
if provider == ssoProviderMicrosoft {
return "sso", ssoProviderMicrosoft, nil
}
return "email", "email", nil
}
func (s *AuthService) ListUserSessions(ctx context.Context, userID []byte, currentSessionID string) ([]UserSession, error) {
if len(userID) != 16 {
return nil, ErrInvalidToken
}
if s.tokens == nil {
return []UserSession{}, nil
}
ids, err := s.loadUserSessionIDs(ctx, userID)
if err != nil {
return nil, err
}
out := make([]UserSession, 0, len(ids))
for i := range ids {
record, err := s.loadSessionRecord(ctx, ids[i])
if err != nil || record == nil {
continue
}
if record.UserID != bytesToString(userID) {
continue
}
out = append(out, UserSession{
ID: record.SessionID,
DeviceName: record.DeviceName,
UserAgent: record.UserAgent,
IPAddress: record.IPAddress,
CreatedAt: record.CreatedAt,
LastActiveAt: record.LastActiveAt,
ActiveNow: record.SessionID == currentSessionID,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].LastActiveAt.After(out[j].LastActiveAt)
})
return out, nil
}
func (s *AuthService) RevokeUserSession(ctx context.Context, userID []byte, sessionID string) error {
if len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
return ErrInvalidToken
}
if s.tokens == nil {
return nil
}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil {
return err
}
if record == nil {
return ErrSessionNotFound
}
if record.UserID != bytesToString(userID) {
return ErrInvalidToken
}
if strings.TrimSpace(record.CurrentJTI) != "" {
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
}
if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" {
_ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID))
}
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
_ = s.removeUserSessionID(ctx, userID, sessionID)
return nil
}
func (s *AuthService) RevokeSessionByID(ctx context.Context, sessionID string) error {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return ErrInvalidToken
}
if s.tokens == nil {
return nil
}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil {
return err
}
if record == nil {
return ErrSessionNotFound
}
userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID))
if err != nil {
return ErrInvalidToken
}
return s.RevokeUserSession(ctx, userID, sessionID)
}
func (s *AuthService) RevokeMicrosoftSessionBySID(ctx context.Context, microsoftSID string) error {
microsoftSID = strings.TrimSpace(microsoftSID)
if microsoftSID == "" {
return ErrInvalidToken
}
if s.tokens == nil {
return nil
}
raw, err := s.tokens.Get(ctx, microsoftSessionKey(microsoftSID))
if err != nil || raw == nil {
return err
}
sessionID := strings.TrimSpace(string(raw))
if sessionID == "" {
_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
return ErrSessionNotFound
}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil {
return err
}
if record == nil {
_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
return ErrSessionNotFound
}
if !strings.EqualFold(strings.TrimSpace(record.MicrosoftSID), microsoftSID) {
_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
return ErrSessionNotFound
}
userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID))
if err != nil {
return ErrInvalidToken
}
return s.RevokeUserSession(ctx, userID, sessionID)
}
func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID []byte) error {
if len(userID) != 16 {
return ErrInvalidToken
}
if s.tokens == nil {
return nil
}
sessionIDs, err := s.loadUserSessionIDs(ctx, userID)
if err != nil {
return err
}
for i := range sessionIDs {
sessionID := strings.TrimSpace(sessionIDs[i])
if sessionID == "" {
continue
}
record, loadErr := s.loadSessionRecord(ctx, sessionID)
if loadErr != nil {
return loadErr
}
if record != nil && record.UserID == bytesToString(userID) && strings.TrimSpace(record.CurrentJTI) != "" {
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" {
_ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID))
}
}
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
}
_ = s.replaceUserSessionIDs(ctx, userID, []string{})
return nil
}
func (s *AuthService) isSessionActive(ctx context.Context, userID []byte, sessionID string) bool {
if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
return false
}
ids, err := s.loadUserSessionIDs(ctx, userID)
if err != nil {
return false
}
active := false
for i := range ids {
if ids[i] == sessionID {
active = true
break
}
}
if !active {
return false
}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil {
return false
}
if record == nil {
return false
}
if record.UserID != bytesToString(userID) {
return false
}
return true
}
func (s *AuthService) isSessionIdleExpired(record *authSessionRecord) bool {
if record == nil || s.cfg.SessionIdleTTL <= 0 || record.LastActiveAt.IsZero() {
return false
}
if !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
return false
}
return record.LastActiveAt.Add(s.cfg.SessionIdleTTL).Before(time.Now().UTC())
}
func (s *AuthService) canRecoverSSOSessionOnIdle(ctx context.Context, userID []byte, record *authSessionRecord) bool {
if record == nil || !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
return false
}
if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil {
// Transient Microsoft failures should not force a re-login on an idle
// session; only a permanent reauth-required error means it cannot
// recover.
return !errors.Is(err, ErrMicrosoftReauthRequired)
}
return true
}
func (s *AuthService) loadSessionRecord(ctx context.Context, sessionID string) (*authSessionRecord, error) {
raw, err := s.tokens.Get(ctx, sessionKey(sessionID))
if err != nil || raw == nil {
return nil, err
}
var record authSessionRecord
if err := json.Unmarshal(raw, &record); err != nil {
return nil, err
}
return &record, nil
}
func (s *AuthService) saveSessionRecord(ctx context.Context, session *authSessionRecord, ttl time.Duration) error {
if session == nil || strings.TrimSpace(session.SessionID) == "" {
return ErrInvalidToken
}
payload, err := json.Marshal(session)
if err != nil {
return err
}
if !session.RefreshExpAt.IsZero() {
remaining := time.Until(session.RefreshExpAt)
if remaining <= 0 {
return s.tokens.Delete(ctx, sessionKey(session.SessionID))
}
ttl = remaining
}
return s.tokens.Set(ctx, sessionKey(session.SessionID), payload, ttl)
}
func (s *AuthService) touchSessionActivity(ctx context.Context, userID []byte, sessionID string) {
if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
return
}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil || record == nil || record.UserID != bytesToString(userID) {
return
}
record.LastActiveAt = time.Now().UTC()
if record.RefreshExpAt.IsZero() {
_ = s.saveSessionRecord(ctx, record, s.cfg.JWTRefreshTTL)
return
}
_ = s.saveSessionRecord(ctx, record, time.Until(record.RefreshExpAt))
}
func (s *AuthService) replaceUserSessionIDs(ctx context.Context, userID []byte, sessionIDs []string) error {
unique := make([]string, 0, len(sessionIDs))
seen := make(map[string]struct{}, len(sessionIDs))
for i := range sessionIDs {
sessionID := strings.TrimSpace(sessionIDs[i])
if sessionID == "" {
continue
}
if _, ok := seen[sessionID]; ok {
continue
}
seen[sessionID] = struct{}{}
unique = append(unique, sessionID)
}
payload, err := json.Marshal(unique)
if err != nil {
return err
}
return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
}
func (s *AuthService) loadUserSessionIDs(ctx context.Context, userID []byte) ([]string, error) {
raw, err := s.tokens.Get(ctx, userSessionsKey(userID))
if err != nil || raw == nil {
return []string{}, err
}
var ids []string
if err := json.Unmarshal(raw, &ids); err != nil {
return []string{}, nil
}
return ids, nil
}
func (s *AuthService) addUserSessionID(ctx context.Context, userID []byte, sessionID string) error {
if strings.TrimSpace(sessionID) == "" {
return nil
}
ids, err := s.loadUserSessionIDs(ctx, userID)
if err != nil {
return err
}
for i := range ids {
if ids[i] == sessionID {
return nil
}
}
ids = append(ids, sessionID)
payload, err := json.Marshal(ids)
if err != nil {
return err
}
return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
}
func (s *AuthService) revokeStaleUserSessions(ctx context.Context, userID []byte, sessionIDs []string, keepSessionID string) error {
expectedUserID := bytesToString(userID)
seen := make(map[string]struct{}, len(sessionIDs))
for i := range sessionIDs {
sessionID := strings.TrimSpace(sessionIDs[i])
if sessionID == "" || sessionID == keepSessionID {
continue
}
if _, ok := seen[sessionID]; ok {
continue
}
seen[sessionID] = struct{}{}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil {
return err
}
if record != nil && record.UserID == expectedUserID && strings.TrimSpace(record.CurrentJTI) != "" {
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
}
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
}
return nil
}
func (s *AuthService) enforceUserDeviceSlots(ctx context.Context, userID []byte, existingSessionIDs []string, current *authSessionRecord) error {
if current == nil {
return nil
}
currentType := strings.TrimSpace(current.DeviceType)
if currentType == "" {
currentType = "unknown"
}
kept := make([]string, 0, len(existingSessionIDs)+1)
seen := map[string]struct{}{}
for i := range existingSessionIDs {
sessionID := strings.TrimSpace(existingSessionIDs[i])
if sessionID == "" || sessionID == current.SessionID {
continue
}
if _, ok := seen[sessionID]; ok {
continue
}
seen[sessionID] = struct{}{}
record, err := s.loadSessionRecord(ctx, sessionID)
if err != nil {
return err
}
if record == nil || record.UserID != bytesToString(userID) {
continue
}
recordType := strings.TrimSpace(record.DeviceType)
if recordType == "" {
recordType = classifyDeviceType(record.UserAgent, "")
}
if currentType != "unknown" && recordType == currentType {
if strings.TrimSpace(record.CurrentJTI) != "" {
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
}
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
continue
}
kept = append(kept, sessionID)
}
kept = append(kept, current.SessionID)
return s.replaceUserSessionIDs(ctx, userID, kept)
}
func (s *AuthService) removeUserSessionID(ctx context.Context, userID []byte, sessionID string) error {
ids, err := s.loadUserSessionIDs(ctx, userID)
if err != nil {
return err
}
out := make([]string, 0, len(ids))
for i := range ids {
if ids[i] == sessionID {
continue
}
out = append(out, ids[i])
}
payload, err := json.Marshal(out)
if err != nil {
return err
}
return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
}
func sessionKey(sessionID string) string {
return "auth:session:" + sessionID
}
func microsoftSessionKey(sessionID string) string {
return "auth:session:microsoft:sid:" + strings.TrimSpace(sessionID)
}
func userSessionsKey(userID []byte) string {
return "auth:sessions:user:" + bytesToString(userID)
}
func deriveDeviceName(userAgent string) string {
ua := strings.ToLower(strings.TrimSpace(userAgent))
if ua == "" {
return "Unknown device"
}
os := "Unknown OS"
switch {
case strings.Contains(ua, "iphone"):
os = "iPhone"
case strings.Contains(ua, "ipad"):
os = "iPad"
case strings.Contains(ua, "android"):
os = "Android"
case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"):
os = "macOS"
case strings.Contains(ua, "windows"):
os = "Windows"
case strings.Contains(ua, "linux"):
os = "Linux"
}
browser := "Unknown browser"
switch {
case strings.Contains(ua, "edg/"):
browser = "Edge"
case strings.Contains(ua, "chrome/"):
browser = "Chrome"
case strings.Contains(ua, "firefox/"):
browser = "Firefox"
case strings.Contains(ua, "safari/"):
browser = "Safari"
}
return browser + " on " + os
}
func classifyDeviceType(userAgent, hint string) string {
if h := normalizeDeviceTypeHint(hint); h != "" {
return h
}
ua := strings.ToLower(strings.TrimSpace(userAgent))
if ua == "" {
return "unknown"
}
switch {
case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "android"), strings.Contains(ua, "mobile"):
return "mobile"
case strings.Contains(ua, "windows"), strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"), strings.Contains(ua, "linux"), strings.Contains(ua, "x11"):
return "desktop"
default:
return "unknown"
}
}
func normalizeDeviceTypeHint(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "mobile":
return "mobile"
case "desktop":
return "desktop"
default:
return ""
}
}

View File

@@ -0,0 +1,411 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
"wucher/internal/domain/auth"
)
const (
webAuthnCeremonyRegistration = "registration"
webAuthnCeremonyLogin = "login"
)
var (
ErrWebAuthnNotConfigured = errors.New("webauthn not configured")
ErrWebAuthnChallengeInvalid = errors.New("invalid or expired webauthn challenge")
ErrWebAuthnPayloadInvalid = errors.New("invalid webauthn payload")
ErrWebAuthnCredentialNotFound = errors.New("webauthn credential not found")
ErrWebAuthnCredentialExists = errors.New("webauthn credential already exists")
ErrWebAuthnCredentialUnavailable = errors.New("webauthn credential is not configured for this account")
ErrWebAuthnVerificationFailed = errors.New("webauthn verification failed")
)
type webAuthnChallengeEnvelope struct {
Ceremony string `json:"ceremony"`
UserID []byte `json:"user_id"`
Session webauthnlib.SessionData `json:"session"`
}
type webAuthnUser struct {
id []byte
name string
displayName string
credentials []webauthnlib.Credential
}
func (u webAuthnUser) WebAuthnID() []byte {
return append([]byte(nil), u.id...)
}
func (u webAuthnUser) WebAuthnName() string {
return u.name
}
func (u webAuthnUser) WebAuthnDisplayName() string {
return u.displayName
}
func (u webAuthnUser) WebAuthnCredentials() []webauthnlib.Credential {
return append([]webauthnlib.Credential(nil), u.credentials...)
}
func (s *AuthService) WebAuthnChallengeTTL() time.Duration {
ttl := s.cfg.WebAuthnChallengeTTL
if ttl <= 0 {
return 5 * time.Minute
}
return ttl
}
func (s *AuthService) IsWebAuthnConfigured(ctx context.Context, userID []byte) (bool, error) {
records, err := s.repo.ListUserWebAuthnCredentials(ctx, userID)
if err != nil {
return false, err
}
return len(records) > 0, nil
}
func (s *AuthService) BeginWebAuthnRegistration(ctx context.Context, userID []byte) (*protocol.CredentialCreation, string, error) {
if len(userID) != 16 {
return nil, "", ErrInvalidToken
}
client, err := s.webAuthnClient()
if err != nil {
return nil, "", err
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return nil, "", err
}
if user == nil {
return nil, "", ErrInvalidToken
}
creds, err := s.loadWebAuthnCredentials(ctx, userID)
if err != nil {
return nil, "", err
}
waUser := buildWebAuthnUser(user, creds)
opts := []webauthnlib.RegistrationOption{
webauthnlib.WithConveyancePreference(protocol.PreferNoAttestation),
webauthnlib.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
}
if len(creds) > 0 {
opts = append(opts, webauthnlib.WithExclusions(webauthnlib.Credentials(creds).CredentialDescriptors()))
}
creation, session, err := client.BeginRegistration(waUser, opts...)
if err != nil {
return nil, "", err
}
challengeToken, err := s.storeWebAuthnChallenge(ctx, webAuthnCeremonyRegistration, userID, session)
if err != nil {
return nil, "", err
}
s.logServiceAudit(ctx, "auth.webauthn.register.begin", userID, true, "challenge_issued", nil)
return creation, challengeToken, nil
}
func (s *AuthService) FinishWebAuthnRegistration(ctx context.Context, userID []byte, challengeToken string, credentialJSON []byte, name string) error {
if len(userID) != 16 {
return ErrInvalidToken
}
client, err := s.webAuthnClient()
if err != nil {
return err
}
session, err := s.consumeWebAuthnChallenge(ctx, challengeToken, webAuthnCeremonyRegistration)
if err != nil {
s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "invalid_challenge", nil)
return err
}
if !bytes.Equal(session.UserID, userID) {
s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "challenge_user_mismatch", nil)
return ErrWebAuthnChallengeInvalid
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrInvalidToken
}
creds, err := s.loadWebAuthnCredentials(ctx, userID)
if err != nil {
return err
}
waUser := buildWebAuthnUser(user, creds)
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credentialJSON))
if err != nil {
s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "invalid_payload", nil)
return ErrWebAuthnPayloadInvalid
}
credential, err := client.CreateCredential(waUser, session.Session, parsed)
if err != nil {
s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, false, "verification_failed", nil)
return ErrWebAuthnVerificationFailed
}
for i := range creds {
if bytes.Equal(creds[i].ID, credential.ID) {
return ErrWebAuthnCredentialExists
}
}
serialized, err := json.Marshal(credential)
if err != nil {
return err
}
rec := &auth.UserWebAuthnCredential{
UserID: userID,
Name: truncate(strings.TrimSpace(name), 100),
CredentialID: append([]byte(nil), credential.ID...),
CredentialJSON: serialized,
}
if err := s.repo.CreateUserWebAuthnCredential(ctx, rec); err != nil {
return err
}
s.logServiceAudit(ctx, "auth.webauthn.register.finish", userID, true, "credential_registered", nil)
return nil
}
func (s *AuthService) BeginWebAuthnLogin(ctx context.Context, email string) (*protocol.CredentialAssertion, string, error) {
client, err := s.webAuthnClient()
if err != nil {
return nil, "", err
}
email = strings.ToLower(strings.TrimSpace(email))
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, "", err
}
if user == nil {
return nil, "", ErrInvalidCredentials
}
if !user.IsActive {
return nil, "", ErrInvalidCredentials
}
if user.EmailVerifiedAt == nil {
return nil, "", ErrEmailNotVerified
}
creds, err := s.loadWebAuthnCredentials(ctx, user.ID)
if err != nil {
return nil, "", err
}
if len(creds) == 0 {
return nil, "", ErrWebAuthnCredentialUnavailable
}
waUser := buildWebAuthnUser(user, creds)
assertion, session, err := client.BeginLogin(waUser, webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return nil, "", err
}
challengeToken, err := s.storeWebAuthnChallenge(ctx, webAuthnCeremonyLogin, user.ID, session)
if err != nil {
return nil, "", err
}
s.logServiceAudit(ctx, "auth.webauthn.login.begin", user.ID, true, "challenge_issued", nil)
return assertion, challengeToken, nil
}
func (s *AuthService) FinishWebAuthnLogin(ctx context.Context, challengeToken string, credentialJSON []byte) (*auth.User, error) {
client, err := s.webAuthnClient()
if err != nil {
return nil, err
}
session, err := s.consumeWebAuthnChallenge(ctx, challengeToken, webAuthnCeremonyLogin)
if err != nil {
s.logServiceAudit(ctx, "auth.webauthn.login.finish", nil, false, "invalid_challenge", nil)
return nil, err
}
if len(session.UserID) != 16 {
return nil, ErrWebAuthnChallengeInvalid
}
user, err := s.repo.GetUserByID(ctx, session.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrInvalidCredentials
}
creds, err := s.loadWebAuthnCredentials(ctx, session.UserID)
if err != nil {
return nil, err
}
if len(creds) == 0 {
return nil, ErrWebAuthnCredentialUnavailable
}
waUser := buildWebAuthnUser(user, creds)
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credentialJSON))
if err != nil {
s.logServiceAudit(ctx, "auth.webauthn.login.finish", session.UserID, false, "invalid_payload", nil)
return nil, ErrWebAuthnPayloadInvalid
}
credential, err := client.ValidateLogin(waUser, session.Session, parsed)
if err != nil {
s.logServiceAudit(ctx, "auth.webauthn.login.finish", session.UserID, false, "verification_failed", nil)
return nil, ErrWebAuthnVerificationFailed
}
serialized, err := json.Marshal(credential)
if err != nil {
return nil, err
}
if err := s.repo.UpdateUserWebAuthnCredential(ctx, session.UserID, credential.ID, serialized, time.Now().UTC()); err != nil {
return nil, err
}
s.logServiceAudit(ctx, "auth.webauthn.login.finish", session.UserID, true, "login_success", nil)
return user, nil
}
func (s *AuthService) webAuthnClient() (*webauthnlib.WebAuthn, error) {
rpID := strings.TrimSpace(s.cfg.WebAuthnRPID)
rpDisplayName := strings.TrimSpace(s.cfg.WebAuthnRPDisplayName)
rpOrigins := make([]string, 0, len(s.cfg.WebAuthnRPOrigins))
for i := range s.cfg.WebAuthnRPOrigins {
origin := strings.TrimSpace(s.cfg.WebAuthnRPOrigins[i])
if origin == "" {
continue
}
rpOrigins = append(rpOrigins, origin)
}
if rpID == "" || len(rpOrigins) == 0 {
return nil, ErrWebAuthnNotConfigured
}
if rpDisplayName == "" {
rpDisplayName = "Wucher"
}
ttl := s.WebAuthnChallengeTTL()
return webauthnlib.New(&webauthnlib.Config{
RPID: rpID,
RPDisplayName: rpDisplayName,
RPOrigins: rpOrigins,
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
RequireResidentKey: protocol.ResidentKeyNotRequired(),
UserVerification: protocol.VerificationRequired,
},
Timeouts: webauthnlib.TimeoutsConfig{
Login: webauthnlib.TimeoutConfig{
Enforce: true,
Timeout: ttl,
TimeoutUVD: ttl,
},
Registration: webauthnlib.TimeoutConfig{
Enforce: true,
Timeout: ttl,
TimeoutUVD: ttl,
},
},
})
}
func (s *AuthService) loadWebAuthnCredentials(ctx context.Context, userID []byte) ([]webauthnlib.Credential, error) {
records, err := s.repo.ListUserWebAuthnCredentials(ctx, userID)
if err != nil {
return nil, err
}
out := make([]webauthnlib.Credential, 0, len(records))
for i := range records {
var credential webauthnlib.Credential
if err := json.Unmarshal(records[i].CredentialJSON, &credential); err != nil {
return nil, err
}
out = append(out, credential)
}
return out, nil
}
func buildWebAuthnUser(user *auth.User, credentials []webauthnlib.Credential) webAuthnUser {
name := strings.TrimSpace(user.Email)
displayName := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName))
if displayName == "" {
displayName = name
}
return webAuthnUser{
id: append([]byte(nil), user.ID...),
name: name,
displayName: displayName,
credentials: credentials,
}
}
func (s *AuthService) storeWebAuthnChallenge(ctx context.Context, ceremony string, userID []byte, session *webauthnlib.SessionData) (string, error) {
if s.tokens == nil {
return "", ErrWebAuthnChallengeInvalid
}
if session == nil {
return "", ErrWebAuthnChallengeInvalid
}
token, err := randomToken(32)
if err != nil {
return "", err
}
envelope := webAuthnChallengeEnvelope{
Ceremony: ceremony,
UserID: append([]byte(nil), userID...),
Session: *session,
}
payload, err := json.Marshal(envelope)
if err != nil {
return "", err
}
if err := s.tokens.Set(ctx, webAuthnChallengeKey(token), payload, s.WebAuthnChallengeTTL()); err != nil {
return "", err
}
return token, nil
}
func (s *AuthService) consumeWebAuthnChallenge(ctx context.Context, token, ceremony string) (*webAuthnChallengeEnvelope, error) {
token = strings.TrimSpace(token)
if token == "" || s.tokens == nil {
return nil, ErrWebAuthnChallengeInvalid
}
raw, err := s.tokens.Get(ctx, webAuthnChallengeKey(token))
if err != nil || raw == nil {
return nil, ErrWebAuthnChallengeInvalid
}
_ = s.tokens.Delete(ctx, webAuthnChallengeKey(token))
var envelope webAuthnChallengeEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, ErrWebAuthnChallengeInvalid
}
if strings.TrimSpace(envelope.Ceremony) != ceremony {
return nil, ErrWebAuthnChallengeInvalid
}
if !envelope.Session.Expires.IsZero() && envelope.Session.Expires.Before(time.Now().UTC()) {
return nil, ErrWebAuthnChallengeInvalid
}
return &envelope, nil
}
func webAuthnChallengeKey(token string) string {
return "auth:webauthn:challenge:" + token
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
package service
import (
"context"
"errors"
"strings"
"time"
"wucher/internal/domain/base"
"wucher/internal/shared/pkg/appctx"
"wucher/internal/shared/pkg/sortkey"
)
type BaseService struct {
repo base.Repository
}
func NewBaseService(repo base.Repository) *BaseService {
return &BaseService{repo: repo}
}
var errInvalidBaseCategoryType = errors.New("invalid base category type")
func (s *BaseService) CreateBase(ctx context.Context, row *base.Base, categoryType string) error {
category, ok := base.NormalizeCategoryType(categoryType)
if !ok {
return errInvalidBaseCategoryType
}
if err := validateBaseCoordinates(row); err != nil {
return err
}
row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType)
row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType)
if row.DefaultStartTimeType == base.ShiftTimeTypeFixed && strings.TrimSpace(row.DefaultShiftStart) == "" {
return errors.New("default_shift_start is required")
}
if row.DefaultEndTimeType == base.ShiftTimeTypeFixed && strings.TrimSpace(row.DefaultShiftEnd) == "" {
return errors.New("default_shift_end is required")
}
return s.repo.CreateBase(ctx, row, category)
}
func (s *BaseService) UpdateBase(ctx context.Context, row *base.Base, categoryType string) error {
category, ok := base.NormalizeCategoryType(categoryType)
if !ok {
return errInvalidBaseCategoryType
}
if err := validateBaseCoordinates(row); err != nil {
return err
}
row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType)
row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType)
if row.DefaultStartTimeType == base.ShiftTimeTypeFixed && strings.TrimSpace(row.DefaultShiftStart) == "" {
return errors.New("default_shift_start is required")
}
if row.DefaultEndTimeType == base.ShiftTimeTypeFixed && strings.TrimSpace(row.DefaultShiftEnd) == "" {
return errors.New("default_shift_end is required")
}
return s.repo.UpdateBase(ctx, row, category)
}
func (s *BaseService) DeleteBase(ctx context.Context, id []byte, categoryType string) (*base.Base, error) {
category, ok := base.NormalizeCategoryType(categoryType)
if !ok {
return nil, errInvalidBaseCategoryType
}
// fetch existing row before deleting so we can return a copy
row, err := s.repo.GetBaseByID(ctx, id, category)
if err != nil {
return nil, err
}
if err := s.repo.DeleteBase(ctx, id, category); err != nil {
return nil, err
}
// if we had a row, populate DeletedAt/DeletedBy to reflect the repository update
if row != nil {
now := time.Now().UTC()
row.DeletedAt = &now
// try to pick actor from context if available
if actor := appctx.GetUserID(ctx); len(actor) > 0 {
row.DeletedBy = make([]byte, len(actor))
copy(row.DeletedBy, actor)
}
}
return row, nil
}
func (s *BaseService) GetBaseByID(ctx context.Context, id []byte, categoryType string) (*base.Base, error) {
category, ok := base.NormalizeCategoryType(categoryType)
if !ok {
return nil, errInvalidBaseCategoryType
}
return s.repo.GetBaseByID(ctx, id, category)
}
func (s *BaseService) ListBases(ctx context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) {
if strings.TrimSpace(categoryType) == "" {
return s.repo.ListBases(ctx, filter, normalizeBaseSort(sort), limit, offset, "", dul)
}
category, ok := base.NormalizeCategoryType(categoryType)
if !ok {
return nil, 0, errInvalidBaseCategoryType
}
return s.repo.ListBases(ctx, filter, normalizeBaseSort(sort), limit, offset, category, dul)
}
func normalizeBaseSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "base", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "base", true)),
"sortkey",
),
sortField("base"),
sortField("base_abbreviation"),
sortField("notes", "note", "notes"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}
func validateBaseCoordinates(row *base.Base) error {
if row == nil {
return nil
}
if row.Latitude < -90 || row.Latitude > 90 {
return errors.New("latitude must be between -90 and 90")
}
if row.Longitude < -180 || row.Longitude > 180 {
return errors.New("longitude must be between -180 and 180")
}
return nil
}
func normalizeBaseShiftTimeType(raw string) string {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "", base.ShiftTimeTypeFixed:
return base.ShiftTimeTypeFixed
case base.ShiftTimeTypeBMCT:
return base.ShiftTimeTypeBMCT
case base.ShiftTimeTypeECET:
return base.ShiftTimeTypeECET
default:
return strings.ToUpper(strings.TrimSpace(raw))
}
}

View File

@@ -0,0 +1,278 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/base"
)
type baseRepoMock struct {
createBaseCalled bool
updateBaseCalled bool
deleteBaseCalled bool
getBaseByIDCalled bool
listBasesCalled bool
lastCreateBase *base.Base
lastCreateCategory string
lastUpdateBase *base.Base
lastUpdateCategory string
lastDeleteBaseID []byte
lastDeleteCategory string
lastGetBaseByIDID []byte
lastGetCategory string
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
lastListCategory string
lastListDUL *bool
getBaseByIDResult *base.Base
listBasesResult []base.Base
listBasesTotal int64
createBaseErr error
updateBaseErr error
deleteBaseErr error
getBaseByIDErr error
listBasesErr error
}
func (m *baseRepoMock) CreateBase(_ context.Context, row *base.Base, categoryType string) error {
m.createBaseCalled = true
m.lastCreateBase = row
m.lastCreateCategory = categoryType
return m.createBaseErr
}
func (m *baseRepoMock) UpdateBase(_ context.Context, row *base.Base, categoryType string) error {
m.updateBaseCalled = true
m.lastUpdateBase = row
m.lastUpdateCategory = categoryType
return m.updateBaseErr
}
func (m *baseRepoMock) DeleteBase(_ context.Context, id []byte, categoryType string) error {
m.deleteBaseCalled = true
m.lastDeleteBaseID = id
m.lastDeleteCategory = categoryType
return m.deleteBaseErr
}
func (m *baseRepoMock) GetBaseByID(_ context.Context, id []byte, categoryType string) (*base.Base, error) {
m.getBaseByIDCalled = true
m.lastGetBaseByIDID = id
m.lastGetCategory = categoryType
return m.getBaseByIDResult, m.getBaseByIDErr
}
func (m *baseRepoMock) ListBases(_ context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) {
m.listBasesCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
m.lastListCategory = categoryType
m.lastListDUL = dul
return m.listBasesResult, m.listBasesTotal, m.listBasesErr
}
func TestNewBaseService(t *testing.T) {
repo := &baseRepoMock{}
svc := NewBaseService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestBaseServiceCreateBase(t *testing.T) {
repo := &baseRepoMock{}
svc := NewBaseService(repo)
row := &base.Base{BaseName: "Jakarta", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00"}
if err := svc.CreateBase(context.Background(), row, "regular"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createBaseCalled || repo.lastCreateBase != row || repo.lastCreateCategory != base.CategoryKeyRegular {
t.Fatalf("expected CreateBase forwarded with regular category")
}
if err := svc.CreateBase(context.Background(), row, "hems"); err != nil {
t.Fatalf("unexpected hems error: %v", err)
}
if repo.lastCreateCategory != base.CategoryKeyHEMS {
t.Fatalf("expected hems category forwarded")
}
repo.createBaseErr = errors.New("create base failed")
if err := svc.CreateBase(context.Background(), row, "regular"); err == nil {
t.Fatalf("expected error from repo")
}
if err := svc.CreateBase(context.Background(), row, "invalid"); err == nil {
t.Fatalf("expected invalid category error")
}
}
func TestBaseServiceUpdateBase(t *testing.T) {
repo := &baseRepoMock{}
svc := NewBaseService(repo)
row := &base.Base{BaseName: "Bandung", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00"}
if err := svc.UpdateBase(context.Background(), row, "regular"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateBaseCalled || repo.lastUpdateBase != row || repo.lastUpdateCategory != base.CategoryKeyRegular {
t.Fatalf("expected UpdateBase forwarded")
}
repo.updateBaseErr = errors.New("update base failed")
if err := svc.UpdateBase(context.Background(), row, "regular"); err == nil {
t.Fatalf("expected error from repo")
}
if err := svc.UpdateBase(context.Background(), row, "x"); err == nil {
t.Fatalf("expected invalid category error")
}
}
func TestBaseServiceDeleteBase(t *testing.T) {
repo := &baseRepoMock{}
svc := NewBaseService(repo)
id := []byte("base-id")
deleted, err := svc.DeleteBase(context.Background(), id, "hems")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deleted != nil {
t.Fatalf("expected deleted result to be nil when repo returns nil")
}
if !repo.deleteBaseCalled || string(repo.lastDeleteBaseID) != string(id) || repo.lastDeleteCategory != base.CategoryKeyHEMS {
t.Fatalf("expected DeleteBase forwarded")
}
repo.deleteBaseErr = errors.New("delete base failed")
if _, err := svc.DeleteBase(context.Background(), id, "hems"); err == nil {
t.Fatalf("expected error from repo")
}
if _, err := svc.DeleteBase(context.Background(), id, "unknown"); err == nil {
t.Fatalf("expected invalid category error")
}
}
func TestBaseServiceGetBaseByID(t *testing.T) {
expected := &base.Base{BaseName: "Surabaya"}
repo := &baseRepoMock{getBaseByIDResult: expected}
svc := NewBaseService(repo)
id := []byte("base-id")
got, err := svc.GetBaseByID(context.Background(), id, "hems")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getBaseByIDCalled || string(repo.lastGetBaseByIDID) != string(id) || repo.lastGetCategory != base.CategoryKeyHEMS {
t.Fatalf("expected GetBaseByID forwarded")
}
repo.getBaseByIDErr = errors.New("get base failed")
if _, err := svc.GetBaseByID(context.Background(), id, "regular"); err == nil {
t.Fatalf("expected error from repo")
}
if _, err := svc.GetBaseByID(context.Background(), id, "invalid"); err == nil {
t.Fatalf("expected invalid category error")
}
}
func TestBaseServiceListBases(t *testing.T) {
repo := &baseRepoMock{
listBasesResult: []base.Base{{BaseName: "Bali"}},
listBasesTotal: 1,
}
svc := NewBaseService(repo)
rows, total, err := svc.ListBases(context.Background(), "find", "-base", 10, 20, "regular", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listBasesCalled {
t.Fatalf("expected ListBases forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "base DESC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 || repo.lastListCategory != base.CategoryKeyRegular {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.ListBases(context.Background(), "find", "sortkey", 10, 20, "regular", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, base ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listBasesErr = errors.New("list bases failed")
if _, _, err := svc.ListBases(context.Background(), "", "unknown", 1, 0, "hems", nil); err == nil {
t.Fatalf("expected error from repo")
}
if repo.lastListSort != "" {
t.Fatalf("expected unknown sort normalized to empty")
}
if repo.lastListCategory != base.CategoryKeyHEMS {
t.Fatalf("expected hems category forwarded")
}
if _, _, err := svc.ListBases(context.Background(), "", "base", 1, 0, "invalid", nil); err == nil {
t.Fatalf("expected invalid category error")
}
repo.listBasesErr = nil
if _, _, err := svc.ListBases(context.Background(), "", "base", 1, 0, "", nil); err != nil {
t.Fatalf("expected empty category to list all, got err=%v", err)
}
if repo.lastListCategory != "" {
t.Fatalf("expected empty category forwarded for unfiltered list, got %q", repo.lastListCategory)
}
}
func TestNormalizeBaseSort(t *testing.T) {
cases := map[string]string{
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, base ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, base ASC",
"base": "base ASC",
"-base": "base DESC",
"base_abbreviation": "base_abbreviation ASC",
"-base_abbreviation": "base_abbreviation DESC",
"note": "notes ASC",
"-note": "notes DESC",
"notes": "notes ASC",
"-notes": "notes DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeBaseSort(in); got != want {
t.Fatalf("normalizeBaseSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,416 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"wucher/internal/domain/base"
"wucher/internal/shared/pkg/uuidv7"
)
type BaseWriteError struct {
Status int
Title string
Detail string
Pointer string
}
func (e *BaseWriteError) Error() string {
if e == nil {
return ""
}
return e.Detail
}
type BaseWriteResult struct {
Row *base.Base
CategoryType string
PreviousFotoAttachmentID []byte
}
type BaseOperationalShiftTimeInput struct {
DateStart time.Time
DateEnd time.Time
StartTimeType string
EndTimeType string
ShiftStart *time.Time
ShiftEnd *time.Time
}
type BaseCreateInput struct {
ID []byte
CategoryType string
BaseName string
BaseAbbreviation string
Address string
Latitude float64
Longitude float64
LandlineNumber string
MobileNumber string
Email string
SortKey *int
SMSAlert bool
Checklist bool
LegTime string
DefaultStartTimeType string
DefaultEndTimeType string
DefaultShiftStart *time.Time
DefaultShiftEnd *time.Time
UTC string
Dry bool
ControlCenter bool
DUL bool
Notes string
HEMSEDCContactIDs [][]byte
MedPaxContactIDs [][]byte
ResponsiblePilotContactIDs [][]byte
OperationalShiftTimes []BaseOperationalShiftTimeInput
IsActive bool
CreatedBy []byte
UpdatedBy []byte
FotoAttachmentID []byte
}
type BaseUpdateInput struct {
ID []byte
CategoryType string
BaseName *string
BaseAbbreviation *string
Address *string
Latitude *float64
Longitude *float64
LandlineNumber *string
MobileNumber *string
Email *string
SortKeySet bool
SortKeyValid bool
SortKeyValue *int
SMSAlert *bool
Checklist *bool
LegTime *string
DefaultStartTimeType *string
DefaultEndTimeType *string
DefaultShiftStart *time.Time
DefaultShiftEnd *time.Time
UTC *string
Dry *bool
ControlCenter *bool
DUL *bool
Notes *string
HEMSEDCContactIDs [][]byte
HEMSEDCContactIDsSet bool
MedPaxContactIDs [][]byte
MedPaxContactIDsSet bool
ResponsiblePilotContactIDs [][]byte
ResponsiblePilotContactIDsSet bool
OperationalShiftTimes []BaseOperationalShiftTimeInput
OperationalShiftTimesSet bool
IsActive *bool
CreatedBy []byte
CreatedBySet bool
UpdatedBy []byte
UpdatedBySet bool
FotoAttachmentID []byte
FotoAttachmentSet bool
}
func (s *BaseService) CreateDetailed(ctx context.Context, in BaseCreateInput) (*BaseWriteResult, error) {
if s == nil || s.repo == nil {
return nil, &BaseWriteError{Status: http.StatusInternalServerError, Title: "Create failed", Detail: "base service is not configured"}
}
category, ok := base.NormalizeCategoryType(in.CategoryType)
if !ok {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "invalid base category type", Pointer: "/data/attributes/base_category"}
}
baseName := strings.TrimSpace(in.BaseName)
if baseName == "" {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "base is required", Pointer: "/data/attributes/base"}
}
if in.Latitude < -90 || in.Latitude > 90 {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "latitude must be between -90 and 90", Pointer: "/data/attributes/latitude"}
}
if in.Longitude < -180 || in.Longitude > 180 {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "longitude must be between -180 and 180", Pointer: "/data/attributes/longitude"}
}
defaultStartTimeType := normalizeShiftTimeType(in.DefaultStartTimeType)
defaultEndTimeType := normalizeShiftTimeType(in.DefaultEndTimeType)
if defaultStartTimeType == base.ShiftTimeTypeFixed && in.DefaultShiftStart == nil {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "default_shift_start is required", Pointer: "/data/attributes/default_shift_start"}
}
if defaultEndTimeType == base.ShiftTimeTypeFixed && in.DefaultShiftEnd == nil {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "default_shift_end is required", Pointer: "/data/attributes/default_shift_end"}
}
row := &base.Base{
ID: append([]byte(nil), in.ID...),
BaseName: baseName,
BaseAbbreviation: strings.TrimSpace(in.BaseAbbreviation),
Address: strings.TrimSpace(in.Address),
Latitude: in.Latitude,
Longitude: in.Longitude,
LandlineNumber: strings.TrimSpace(in.LandlineNumber),
MobileNumber: strings.TrimSpace(in.MobileNumber),
Email: strings.TrimSpace(in.Email),
SortKey: cloneIntPtr(in.SortKey),
SMSAlert: in.SMSAlert,
Checklist: in.Checklist,
LegTime: strings.TrimSpace(in.LegTime),
DefaultStartTimeType: defaultStartTimeType,
DefaultEndTimeType: defaultEndTimeType,
DefaultShiftStart: timePtrToClockString(in.DefaultShiftStart),
DefaultShiftEnd: timePtrToClockString(in.DefaultShiftEnd),
UTC: normalizeUTCString(in.UTC),
Dry: in.Dry,
ControlCenter: in.ControlCenter,
DUL: in.DUL,
Notes: strings.TrimSpace(in.Notes),
IsActive: in.IsActive,
HEMSEDCContactIDs: appendUniqueIDs(in.HEMSEDCContactIDs),
MedPaxContactIDs: appendUniqueIDs(in.MedPaxContactIDs),
ResponsiblePilotContactIDs: appendUniqueIDs(in.ResponsiblePilotContactIDs),
OperationalShiftTimes: cloneOperationalShiftTimeInputs(in.OperationalShiftTimes),
CreatedBy: append([]byte(nil), in.CreatedBy...),
UpdatedBy: append([]byte(nil), in.UpdatedBy...),
FotoAttachmentID: append([]byte(nil), in.FotoAttachmentID...),
}
if len(row.ID) == 0 {
row.ID = uuidv7.MustBytes()
}
if err := s.CreateBase(ctx, row, category); err != nil {
return nil, &BaseWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()}
}
created, err := s.GetBaseByID(ctx, row.ID, category)
if err != nil {
return nil, &BaseWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()}
}
if created == nil {
created = row
}
return &BaseWriteResult{Row: created, CategoryType: category}, nil
}
func (s *BaseService) UpdateDetailed(ctx context.Context, in BaseUpdateInput) (*BaseWriteResult, error) {
if s == nil || s.repo == nil {
return nil, &BaseWriteError{Status: http.StatusInternalServerError, Title: "Update failed", Detail: "base service is not configured"}
}
category, ok := base.NormalizeCategoryType(in.CategoryType)
if !ok {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "invalid base category type", Pointer: "/data/attributes/base_category"}
}
if len(in.ID) != 16 {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "uuid is invalid UUID", Pointer: "/path/uuid"}
}
existing, err := s.GetBaseByID(ctx, in.ID, category)
if err != nil {
return nil, &BaseWriteError{Status: http.StatusNotFound, Title: "Not found", Detail: categoryNotFoundDetail(category)}
}
if existing == nil {
return nil, &BaseWriteError{Status: http.StatusNotFound, Title: "Not found", Detail: categoryNotFoundDetail(category)}
}
prevFotoAttachmentID := append([]byte(nil), existing.FotoAttachmentID...)
if in.Latitude != nil && (*in.Latitude < -90 || *in.Latitude > 90) {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "latitude must be between -90 and 90", Pointer: "/data/attributes/latitude"}
}
if in.Longitude != nil && (*in.Longitude < -180 || *in.Longitude > 180) {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "longitude must be between -180 and 180", Pointer: "/data/attributes/longitude"}
}
if in.BaseName != nil {
v := strings.TrimSpace(*in.BaseName)
if v == "" {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "base cannot be empty", Pointer: "/data/attributes/base"}
}
existing.BaseName = v
}
if in.BaseAbbreviation != nil {
existing.BaseAbbreviation = strings.TrimSpace(*in.BaseAbbreviation)
}
if in.Address != nil {
existing.Address = strings.TrimSpace(*in.Address)
}
if in.Latitude != nil {
existing.Latitude = *in.Latitude
}
if in.Longitude != nil {
existing.Longitude = *in.Longitude
}
if in.LandlineNumber != nil {
existing.LandlineNumber = strings.TrimSpace(*in.LandlineNumber)
}
if in.MobileNumber != nil {
existing.MobileNumber = strings.TrimSpace(*in.MobileNumber)
}
if in.Email != nil {
existing.Email = strings.TrimSpace(*in.Email)
}
if in.SortKeySet {
if in.SortKeyValid {
existing.SortKey = cloneIntPtr(in.SortKeyValue)
} else {
existing.SortKey = nil
}
}
if in.SMSAlert != nil {
existing.SMSAlert = *in.SMSAlert
}
if in.Checklist != nil {
existing.Checklist = *in.Checklist
}
if in.LegTime != nil {
existing.LegTime = strings.TrimSpace(*in.LegTime)
}
if in.DefaultStartTimeType != nil {
existing.DefaultStartTimeType = normalizeShiftTimeType(*in.DefaultStartTimeType)
}
if in.DefaultEndTimeType != nil {
existing.DefaultEndTimeType = normalizeShiftTimeType(*in.DefaultEndTimeType)
}
if in.DefaultShiftStart != nil {
existing.DefaultShiftStart = timePtrToClockString(in.DefaultShiftStart)
}
if in.DefaultShiftEnd != nil {
existing.DefaultShiftEnd = timePtrToClockString(in.DefaultShiftEnd)
}
if normalizeShiftTimeType(existing.DefaultStartTimeType) != base.ShiftTimeTypeFixed {
existing.DefaultShiftStart = ""
}
if normalizeShiftTimeType(existing.DefaultEndTimeType) != base.ShiftTimeTypeFixed {
existing.DefaultShiftEnd = ""
}
if normalizeShiftTimeType(existing.DefaultStartTimeType) == base.ShiftTimeTypeFixed && strings.TrimSpace(existing.DefaultShiftStart) == "" {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "default_shift_start is required", Pointer: "/data/attributes/default_shift_start"}
}
if normalizeShiftTimeType(existing.DefaultEndTimeType) == base.ShiftTimeTypeFixed && strings.TrimSpace(existing.DefaultShiftEnd) == "" {
return nil, &BaseWriteError{Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "default_shift_end is required", Pointer: "/data/attributes/default_shift_end"}
}
if in.UTC != nil {
existing.UTC = normalizeUTCString(*in.UTC)
}
if in.Dry != nil {
existing.Dry = *in.Dry
}
if in.ControlCenter != nil {
existing.ControlCenter = *in.ControlCenter
}
if in.DUL != nil {
existing.DUL = *in.DUL
}
if in.Notes != nil {
existing.Notes = strings.TrimSpace(*in.Notes)
}
if in.HEMSEDCContactIDsSet {
existing.HEMSEDCContactIDs = appendUniqueIDs(in.HEMSEDCContactIDs)
}
if in.MedPaxContactIDsSet {
existing.MedPaxContactIDs = appendUniqueIDs(in.MedPaxContactIDs)
}
if in.ResponsiblePilotContactIDsSet {
existing.ResponsiblePilotContactIDs = appendUniqueIDs(in.ResponsiblePilotContactIDs)
}
if in.OperationalShiftTimesSet {
existing.OperationalShiftTimes = cloneOperationalShiftTimeInputs(in.OperationalShiftTimes)
if existing.OperationalShiftTimes == nil {
existing.OperationalShiftTimes = []base.BaseOperationalShiftTime{}
}
} else {
existing.OperationalShiftTimes = nil
}
if in.CreatedBySet {
existing.CreatedBy = append([]byte(nil), in.CreatedBy...)
}
if in.UpdatedBySet {
existing.UpdatedBy = append([]byte(nil), in.UpdatedBy...)
}
if in.FotoAttachmentSet {
existing.FotoAttachmentID = append([]byte(nil), in.FotoAttachmentID...)
}
if in.IsActive != nil {
existing.IsActive = *in.IsActive
}
if err := s.UpdateBase(ctx, existing, category); err != nil {
return nil, &BaseWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()}
}
updated, err := s.GetBaseByID(ctx, existing.ID, category)
if err != nil {
return nil, &BaseWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()}
}
if updated == nil {
updated = existing
}
return &BaseWriteResult{Row: updated, CategoryType: category, PreviousFotoAttachmentID: prevFotoAttachmentID}, nil
}
func cloneOperationalShiftTimeInputs(items []BaseOperationalShiftTimeInput) []base.BaseOperationalShiftTime {
if len(items) == 0 {
return nil
}
out := make([]base.BaseOperationalShiftTime, 0, len(items))
for i := range items {
dateStart := items[i].DateStart.UTC()
dateEnd := items[i].DateEnd.UTC()
out = append(out, base.BaseOperationalShiftTime{
DateStart: &dateStart,
DateEnd: &dateEnd,
StartTimeType: normalizeShiftTimeType(items[i].StartTimeType),
EndTimeType: normalizeShiftTimeType(items[i].EndTimeType),
ShiftStart: timePtrToClockString(items[i].ShiftStart),
ShiftEnd: timePtrToClockString(items[i].ShiftEnd),
})
}
return out
}
func timePtrToClockString(v *time.Time) string {
if v == nil {
return ""
}
return v.UTC().Format("15:04:05")
}
func normalizeUTCString(raw string) string {
return strings.TrimSpace(raw)
}
func normalizeShiftTimeType(raw string) string {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "", base.ShiftTimeTypeFixed:
return base.ShiftTimeTypeFixed
case base.ShiftTimeTypeBMCT:
return base.ShiftTimeTypeBMCT
case base.ShiftTimeTypeECET:
return base.ShiftTimeTypeECET
default:
return strings.ToUpper(strings.TrimSpace(raw))
}
}
func appendUniqueIDs(values [][]byte) [][]byte {
if len(values) == 0 {
return nil
}
out := make([][]byte, 0, len(values))
seen := map[string]struct{}{}
for i := range values {
if len(values[i]) != 16 {
continue
}
key := string(values[i])
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, append([]byte(nil), values[i]...))
}
return out
}
func categoryNotFoundDetail(category string) string {
if category == base.CategoryKeyHEMS {
return "hems base not found"
}
return "base not found"
}

View File

@@ -0,0 +1,149 @@
package service
import (
"context"
"fmt"
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
"wucher/internal/shared/pkg/appctx"
)
type BeforeFlightInspectionService struct {
repo beforeflightinspection.Repository
}
func NewBeforeFlightInspectionService(repo beforeflightinspection.Repository) *BeforeFlightInspectionService {
return &BeforeFlightInspectionService{repo: repo}
}
func (s *BeforeFlightInspectionService) Upsert(ctx context.Context, flightInspectionID []byte, req *beforeflightinspection.UpsertRequest) (*beforeflightinspection.BeforeFlightInspection, error) {
if len(flightInspectionID) != 16 {
return nil, &beforeflightinspection.ValidationError{Status: 400, Title: "Invalid flight_inspection_id", Detail: "flight_inspection_id must be a valid UUID"}
}
exists, err := s.repo.FlightInspectionExists(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to check flight inspection: %w", err)
}
if !exists {
return nil, &beforeflightinspection.ValidationError{Status: 404, Title: "Flight inspection not found", Detail: "The specified flight inspection does not exist"}
}
caps, err := s.repo.GetHelicopterCapabilities(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to get helicopter capabilities: %w", err)
}
if err := s.validateRequest(req, caps); err != nil {
return nil, err
}
row := &beforeflightinspection.BeforeFlightInspection{
FlightInspectionID: flightInspectionID,
OilEngineNR1Checked: req.OilEngineNR1Checked,
OilEngineNR2Checked: req.OilEngineNR2Checked,
HydraulicLHChecked: req.HydraulicLHChecked,
HydraulicRHChecked: req.HydraulicRHChecked,
OilTransmissionMGBChecked: req.OilTransmissionMGBChecked,
OilTransmissionIGBChecked: req.OilTransmissionIGBChecked,
OilTransmissionTGBChecked: req.OilTransmissionTGBChecked,
FuelAmount: req.FuelAmount,
FuelUnit: s.normalizeFuelUnit(req.FuelUnit),
FuelAddedAmount: req.FuelAddedAmount,
}
// Keep update idempotent on unique flight_inspection_id.
// If a row already exists, update it instead of inserting a new one.
existing, err := s.repo.GetByFlightInspectionID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to get before flight inspection: %w", err)
}
if existing != nil {
row.ID = append([]byte(nil), existing.ID...)
row.CreatedBy = append([]byte(nil), existing.CreatedBy...)
row.CreatedAt = existing.CreatedAt
}
userID := appctx.GetUserID(ctx)
if userID != nil {
if existing == nil {
row.CreatedBy = userID
}
row.UpdatedBy = userID
}
if err := s.repo.Upsert(ctx, row); err != nil {
return nil, fmt.Errorf("failed to upsert before flight inspection: %w", err)
}
return row, nil
}
func (s *BeforeFlightInspectionService) GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*beforeflightinspection.BeforeFlightInspection, error) {
if len(flightInspectionID) != 16 {
return nil, &beforeflightinspection.ValidationError{Status: 400, Title: "Invalid flight_inspection_id", Detail: "flight_inspection_id must be a valid UUID"}
}
exists, err := s.repo.FlightInspectionExists(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to check flight inspection: %w", err)
}
if !exists {
return nil, &beforeflightinspection.ValidationError{Status: 404, Title: "Flight inspection not found", Detail: "The specified flight inspection does not exist"}
}
row, err := s.repo.GetByFlightInspectionID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to get before flight inspection: %w", err)
}
return row, nil
}
func (s *BeforeFlightInspectionService) validateRequest(req *beforeflightinspection.UpsertRequest, caps *beforeflightinspection.HelicopterCapabilities) error {
if req.FuelAmount != nil && *req.FuelAmount < 0 {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid fuel_amount", Detail: "fuel_amount cannot be negative"}
}
if req.FuelAddedAmount != nil && *req.FuelAddedAmount < 0 {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid fuel_added_amount", Detail: "fuel_added_amount cannot be negative"}
}
if req.FuelUnit != nil {
if _, valid := beforeflightinspection.NormalizeFuelUnit(*req.FuelUnit); !valid {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid fuel_unit", Detail: "fuel_unit must be one of: LT, KG, POUND"}
}
}
// Capability=true means corresponding check must be explicitly true.
if caps.NR1 && (req.OilEngineNR1Checked == nil || !*req.OilEngineNR1Checked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid oil_engine_nr1_checked", Detail: "oil_engine_nr1_checked must be true for this helicopter"}
}
if caps.NR2 && (req.OilEngineNR2Checked == nil || !*req.OilEngineNR2Checked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid oil_engine_nr2_checked", Detail: "oil_engine_nr2_checked must be true for this helicopter"}
}
if caps.LH && (req.HydraulicLHChecked == nil || !*req.HydraulicLHChecked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid hydraulic_lh_checked", Detail: "hydraulic_lh_checked must be true for this helicopter"}
}
if caps.RH && (req.HydraulicRHChecked == nil || !*req.HydraulicRHChecked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid hydraulic_rh_checked", Detail: "hydraulic_rh_checked must be true for this helicopter"}
}
if caps.MGB && (req.OilTransmissionMGBChecked == nil || !*req.OilTransmissionMGBChecked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid oil_transmission_mgb_checked", Detail: "oil_transmission_mgb_checked must be true for this helicopter"}
}
if caps.IGB && (req.OilTransmissionIGBChecked == nil || !*req.OilTransmissionIGBChecked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid oil_transmission_igb_checked", Detail: "oil_transmission_igb_checked must be true for this helicopter"}
}
if caps.TGB && (req.OilTransmissionTGBChecked == nil || !*req.OilTransmissionTGBChecked) {
return &beforeflightinspection.ValidationError{Status: 422, Title: "Invalid oil_transmission_tgb_checked", Detail: "oil_transmission_tgb_checked must be true for this helicopter"}
}
return nil
}
func (s *BeforeFlightInspectionService) normalizeFuelUnit(unit *string) string {
if unit == nil {
return ""
}
normalized, _ := beforeflightinspection.NormalizeFuelUnit(*unit)
return normalized
}

View File

@@ -0,0 +1,98 @@
package service
import (
"context"
"testing"
"time"
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
"wucher/internal/shared/pkg/appctx"
)
type beforeFlightInspectionRepoStub struct {
exists bool
caps *beforeflightinspection.HelicopterCapabilities
existing *beforeflightinspection.BeforeFlightInspection
upserted *beforeflightinspection.BeforeFlightInspection
upsertCallCount int
lookupCallCount int
existsCallCount int
capabilityChecked int
}
func (r *beforeFlightInspectionRepoStub) Upsert(_ context.Context, row *beforeflightinspection.BeforeFlightInspection) error {
r.upsertCallCount++
r.upserted = row
return nil
}
func (r *beforeFlightInspectionRepoStub) GetByFlightInspectionID(_ context.Context, _ []byte) (*beforeflightinspection.BeforeFlightInspection, error) {
r.lookupCallCount++
return r.existing, nil
}
func (r *beforeFlightInspectionRepoStub) FlightInspectionExists(_ context.Context, _ []byte) (bool, error) {
r.existsCallCount++
return r.exists, nil
}
func (r *beforeFlightInspectionRepoStub) GetHelicopterCapabilities(_ context.Context, _ []byte) (*beforeflightinspection.HelicopterCapabilities, error) {
r.capabilityChecked++
return r.caps, nil
}
func TestBeforeFlightInspectionServiceUpsertUsesExistingID(t *testing.T) {
t.Parallel()
flightInspectionID := []byte{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25}
existingID := []byte{0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45}
createdBy := []byte{0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65}
updatedBy := []byte{0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85}
createdAt := time.Date(2026, time.April, 16, 8, 30, 0, 0, time.UTC)
repo := &beforeFlightInspectionRepoStub{
exists: true,
caps: &beforeflightinspection.HelicopterCapabilities{},
existing: &beforeflightinspection.BeforeFlightInspection{
ID: existingID,
FlightInspectionID: flightInspectionID,
CreatedBy: createdBy,
CreatedAt: createdAt,
},
}
svc := NewBeforeFlightInspectionService(repo)
fuelUnit := "lt"
req := &beforeflightinspection.UpsertRequest{FuelUnit: &fuelUnit}
ctx := appctx.WithUserID(context.Background(), updatedBy)
out, err := svc.Upsert(ctx, flightInspectionID, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil output")
}
if repo.upsertCallCount != 1 {
t.Fatalf("expected Upsert to be called once, got %d", repo.upsertCallCount)
}
if repo.upserted == nil {
t.Fatal("expected upserted payload to be captured")
}
if string(repo.upserted.ID) != string(existingID) {
t.Fatalf("expected existing ID to be reused, got %q", string(repo.upserted.ID))
}
if string(repo.upserted.CreatedBy) != string(createdBy) {
t.Fatalf("expected existing CreatedBy to be preserved, got %q", string(repo.upserted.CreatedBy))
}
if !repo.upserted.CreatedAt.Equal(createdAt) {
t.Fatalf("expected CreatedAt to be preserved, got %v", repo.upserted.CreatedAt)
}
if string(repo.upserted.UpdatedBy) != string(updatedBy) {
t.Fatalf("expected UpdatedBy to be set from context, got %q", string(repo.upserted.UpdatedBy))
}
if repo.upserted.FuelUnit != "LT" {
t.Fatalf("expected fuel unit normalized to LT, got %q", repo.upserted.FuelUnit)
}
}

View File

@@ -0,0 +1,74 @@
package service
import (
"context"
complaint "wucher/internal/domain/complaint"
"wucher/internal/shared/pkg/userctx"
)
type ComplaintService struct{ repo complaint.Repository }
func NewComplaintService(repo complaint.Repository) *ComplaintService {
return &ComplaintService{repo: repo}
}
func (s *ComplaintService) Create(ctx context.Context, row *complaint.Complaint) error {
return s.repo.Create(ctx, row)
}
func (s *ComplaintService) Update(ctx context.Context, row *complaint.Complaint) error {
return s.repo.Update(ctx, row)
}
func (s *ComplaintService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *ComplaintService) GetByID(ctx context.Context, id []byte) (*complaint.Complaint, error) {
return s.repo.GetByID(ctx, id)
}
func (s *ComplaintService) ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error) {
return s.repo.ListByHelicopter(ctx, helicopterID, limit, offset)
}
func (s *ComplaintService) List(ctx context.Context, filter complaint.ListFilter, sort string, limit, offset int) ([]complaint.Complaint, int64, error) {
return s.repo.List(ctx, filter, sort, limit, offset)
}
func (s *ComplaintService) HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error) {
return s.repo.HelicopterHasOpenComplaint(ctx, helicopterID)
}
func (s *ComplaintService) ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error) {
return s.repo.ActiveGroundingByHelicopterIDs(ctx, helicopterIDs)
}
func (s *ComplaintService) OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error) {
return s.repo.OpenByHelicopterIDs(ctx, helicopterIDs)
}
func (s *ComplaintService) HelicopterHasUnactionedComplaint(ctx context.Context, helicopterID []byte) (bool, error) {
return s.repo.HelicopterHasUnactionedComplaint(ctx, helicopterID)
}
func (s *ComplaintService) MarkFixedByHelicopter(ctx context.Context, helicopterID []byte, easaID []byte, actor []byte) error {
return s.repo.MarkFixedByHelicopter(ctx, helicopterID, easaID, actor)
}
func (s *ComplaintService) MarkFixedByComplaint(ctx context.Context, complaintID []byte, easaID []byte, actor []byte) error {
return s.repo.MarkFixedByComplaint(ctx, complaintID, easaID, actor)
}
func (s *ComplaintService) MarkFixedByFlight(ctx context.Context, flightID []byte, easaID []byte, actor []byte) error {
return s.repo.MarkFixedByFlight(ctx, flightID, easaID, actor)
}
func (s *ComplaintService) ResolveUserNames(ctx context.Context, complaints []complaint.Complaint) (map[string]string, error) {
out := make(map[string]string)
add := func(id []byte) {
if len(id) == 0 {
return
}
if _, ok := out[string(id)]; ok {
return
}
if n := userctx.GetDisplayName(ctx, id); n != "" {
out[string(id)] = n
}
}
for i := range complaints {
add(complaints[i].ReportedBy)
add(complaints[i].MELClassifiedBy)
add(complaints[i].NSRDecidedBy)
}
return out, nil
}

View File

@@ -0,0 +1,141 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/complaint"
"wucher/internal/shared/pkg/userctx"
)
type stubComplaintRepo struct {
row *complaint.Complaint
open bool
unactioned bool
active []complaint.Complaint
createErr error
created *complaint.Complaint
markedHeli []byte
markedComplaint []byte
markedFlight []byte
}
func (s *stubComplaintRepo) Create(_ context.Context, row *complaint.Complaint) error {
s.created = row
return s.createErr
}
func (s *stubComplaintRepo) Update(_ context.Context, _ *complaint.Complaint) error { return nil }
func (s *stubComplaintRepo) Delete(_ context.Context, _, _ []byte) error { return nil }
func (s *stubComplaintRepo) GetByID(_ context.Context, _ []byte) (*complaint.Complaint, error) {
return s.row, nil
}
func (s *stubComplaintRepo) ListByHelicopter(_ context.Context, _ []byte, _, _ int) ([]complaint.Complaint, int64, error) {
return s.active, int64(len(s.active)), nil
}
func (s *stubComplaintRepo) List(_ context.Context, _ complaint.ListFilter, _ string, _, _ int) ([]complaint.Complaint, int64, error) {
return s.active, int64(len(s.active)), nil
}
func (s *stubComplaintRepo) HelicopterHasOpenComplaint(_ context.Context, _ []byte) (bool, error) {
return s.open, nil
}
func (s *stubComplaintRepo) ActiveGroundingByHelicopterIDs(_ context.Context, _ [][]byte) ([]complaint.Complaint, error) {
return s.active, nil
}
func (s *stubComplaintRepo) OpenByHelicopterIDs(_ context.Context, _ [][]byte) ([]complaint.Complaint, error) {
return s.active, nil
}
func (s *stubComplaintRepo) HelicopterHasUnactionedComplaint(_ context.Context, _ []byte) (bool, error) {
return s.unactioned, nil
}
func (s *stubComplaintRepo) MarkFixedByHelicopter(_ context.Context, helicopterID, _, _ []byte) error {
s.markedHeli = helicopterID
return nil
}
func (s *stubComplaintRepo) MarkFixedByComplaint(_ context.Context, complaintID, _, _ []byte) error {
s.markedComplaint = complaintID
return nil
}
func (s *stubComplaintRepo) MarkFixedByFlight(_ context.Context, flightID, _, _ []byte) error {
s.markedFlight = flightID
return nil
}
func TestComplaintServiceDelegation(t *testing.T) {
repo := &stubComplaintRepo{
row: &complaint.Complaint{},
open: true,
unactioned: true,
active: []complaint.Complaint{{}},
}
svc := NewComplaintService(repo)
ctx := context.Background()
if err := svc.Create(ctx, &complaint.Complaint{}); err != nil {
t.Fatalf("Create: %v", err)
}
if err := svc.Update(ctx, &complaint.Complaint{}); err != nil {
t.Fatalf("Update: %v", err)
}
if err := svc.Delete(ctx, []byte("x"), nil); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := svc.GetByID(ctx, []byte("x")); err != nil {
t.Fatalf("GetByID: %v", err)
}
if rows, _, err := svc.ListByHelicopter(ctx, []byte("h"), 10, 0); err != nil || len(rows) != 1 {
t.Fatalf("ListByHelicopter: %v", err)
}
if _, _, err := svc.List(ctx, complaint.ListFilter{}, "", 10, 0); err != nil {
t.Fatalf("List: %v", err)
}
if open, err := svc.HelicopterHasOpenComplaint(ctx, []byte("h")); err != nil || !open {
t.Fatalf("HelicopterHasOpenComplaint: %v", err)
}
if un, err := svc.HelicopterHasUnactionedComplaint(ctx, []byte("h")); err != nil || !un {
t.Fatalf("HelicopterHasUnactionedComplaint: %v", err)
}
if _, err := svc.ActiveGroundingByHelicopterIDs(ctx, [][]byte{[]byte("h")}); err != nil {
t.Fatalf("ActiveGroundingByHelicopterIDs: %v", err)
}
if err := svc.MarkFixedByHelicopter(ctx, []byte("heli"), []byte("easa"), []byte("actor")); err != nil || string(repo.markedHeli) != "heli" {
t.Fatalf("MarkFixedByHelicopter: %v", err)
}
repo.createErr = errors.New("boom")
if err := svc.Create(ctx, &complaint.Complaint{}); err == nil {
t.Fatal("expected create error to propagate")
}
}
type stubNameGetter struct{}
func (stubNameGetter) GetLastName(_ context.Context, id []byte) (string, error) {
return "Name-" + string(id), nil
}
func TestComplaintServiceResolveUserNames(t *testing.T) {
svc := NewComplaintService(&stubComplaintRepo{})
ctx := userctx.WithGetter(context.Background(), stubNameGetter{})
rows := []complaint.Complaint{
{ReportedBy: []byte("u1"), MELClassifiedBy: []byte("u1"), NSRDecidedBy: []byte("u2")},
{}, // all-empty ids skipped
}
names, err := svc.ResolveUserNames(ctx, rows)
if err != nil {
t.Fatalf("err: %v", err)
}
if names["u1"] != "Name-u1" || names["u2"] != "Name-u2" {
t.Fatalf("unexpected names: %v", names)
}
if len(names) != 2 {
t.Fatalf("dup/empty ids should be deduped/skipped, got %v", names)
}
// No getter in context -> no names resolved.
empty, _ := svc.ResolveUserNames(context.Background(), rows)
if len(empty) != 0 {
t.Fatalf("expected no names without getter, got %v", empty)
}
}

View File

@@ -0,0 +1,158 @@
package service
import (
"context"
"errors"
"strings"
"wucher/internal/domain/contact"
"wucher/internal/shared/pkg/sortkey"
)
type pinVerifier interface {
ConsumeSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error
}
type ContactService struct {
repo contact.Repository
pinVerifier pinVerifier
}
func NewContactService(repo contact.Repository, pinVerifier pinVerifier) *ContactService {
return &ContactService{repo: repo, pinVerifier: pinVerifier}
}
func (s *ContactService) ListContacts(ctx context.Context, filter contact.ListFilter) ([]contact.ContactListItem, int64, error) {
if s.repo == nil {
return nil, 0, errors.New("contact repository not configured")
}
filter.Sort = normalizeContactSort(filter.Sort)
return s.repo.ListContacts(ctx, filter)
}
func (s *ContactService) GetContactByID(ctx context.Context, userID []byte) (*contact.ContactListItem, error) {
if s.repo == nil {
return nil, errors.New("contact repository not configured")
}
return s.repo.GetContactByID(ctx, userID)
}
func (s *ContactService) IsActorAdmin(ctx context.Context) (bool, error) {
if s.repo == nil {
return false, errors.New("contact repository not configured")
}
return s.repo.IsActorAdmin(ctx)
}
func (s *ContactService) ResolveRoleCodeByID(ctx context.Context, roleID []byte) (string, error) {
if s.repo == nil {
return "", errors.New("contact repository not configured")
}
return s.repo.ResolveRoleCodeByID(ctx, roleID)
}
func (s *ContactService) CreateContact(ctx context.Context, in contact.CreateInput) ([]byte, error) {
if s.repo == nil {
return nil, errors.New("contact repository not configured")
}
return s.repo.CreateContact(ctx, in)
}
func (s *ContactService) UpdateContact(ctx context.Context, in contact.UpdateInput) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdateContact(ctx, in)
}
func (s *ContactService) DeleteContact(ctx context.Context, userID []byte) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.DeleteContact(ctx, userID)
}
func (s *ContactService) UpdateContactStatus(ctx context.Context, userID []byte, isActive bool) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdateContactStatus(ctx, userID, isActive)
}
func (s *ContactService) UpdateContactRole(ctx context.Context, userID []byte, roleCode string) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdateContactRole(ctx, userID, roleCode)
}
func (s *ContactService) UpdatePilotCategory(ctx context.Context, userID []byte, category string) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdatePilotCategory(ctx, userID, category)
}
func (s *ContactService) UpdateLicense(ctx context.Context, userID []byte, licenseNo *string) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdateLicense(ctx, userID, licenseNo)
}
func (s *ContactService) UpdateChiefFlags(ctx context.Context, userID []byte, patch contact.ProfilePatch) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdateChiefFlags(ctx, userID, patch)
}
func (s *ContactService) UpdateResponsibleComplaints(ctx context.Context, userID []byte, value bool) error {
if s.repo == nil {
return errors.New("contact repository not configured")
}
return s.repo.UpdateResponsibleComplaints(ctx, userID, value)
}
func (s *ContactService) BulkUpdateStatus(ctx context.Context, userIDs [][]byte, isActive bool) (int64, error) {
if s.repo == nil {
return 0, errors.New("contact repository not configured")
}
return s.repo.BulkUpdateStatus(ctx, userIDs, isActive)
}
func (s *ContactService) CheckSensitiveAction(ctx context.Context, userID []byte, actionToken, action string) error {
if len(userID) != 16 {
return ErrInvalidPINAction
}
if s.pinVerifier == nil {
return errors.New("pin verifier not configured")
}
action = strings.TrimSpace(action)
actionToken = strings.TrimSpace(actionToken)
if action == "" || actionToken == "" {
return ErrInvalidPINAction
}
return s.pinVerifier.ConsumeSecurityPINActionToken(ctx, userID, action, actionToken)
}
func normalizeContactSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("users", "is_active", "sortkey", "first_name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("users", "is_active", "sortkey", "first_name", true)),
"sortkey",
),
sortExpr(
"COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') ASC",
"COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') DESC",
"note",
),
sortField("users.is_active", "is_active"),
sortField("users.first_name", "first_name", "name"),
sortField("users.last_name", "last_name"),
sortField("users.email", "email"),
sortField("users.created_at", "created_at"),
sortField("users.updated_at", "updated_at"),
)
}

View File

@@ -0,0 +1,504 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/contact"
"wucher/internal/shared/pkg/uuidv7"
)
type mockContactRepo struct {
listItems []contact.ContactListItem
listTotal int64
listErr error
lastFilter contact.ListFilter
getItem *contact.ContactListItem
getErr error
lastGetID []byte
isActorAdmin bool
isActorAdminErr error
resolveRoleCode string
resolveRoleErr error
lastResolveID []byte
createID []byte
createErr error
lastCreate contact.CreateInput
updateErr error
lastUpdate contact.UpdateInput
deleteErr error
lastDeleteID []byte
updateStatusErr error
lastStatusID []byte
lastStatusIsActive bool
updateRoleErr error
lastRoleID []byte
lastRoleCode string
updatePilotErr error
lastPilotID []byte
lastCategory string
updateLicenseErr error
lastLicenseID []byte
lastLicenseNo *string
updateChiefFlagsErr error
lastChiefID []byte
lastChiefPatch contact.ProfilePatch
updateResponsibleErr error
lastResponsibleID []byte
lastResponsibleValue bool
bulkErr error
bulkCount int64
lastBulkIDs [][]byte
lastBulkActive bool
}
func (m *mockContactRepo) ListContacts(_ context.Context, filter contact.ListFilter) ([]contact.ContactListItem, int64, error) {
m.lastFilter = filter
if m.listErr != nil {
return nil, 0, m.listErr
}
return m.listItems, m.listTotal, nil
}
func (m *mockContactRepo) GetContactByID(_ context.Context, userID []byte) (*contact.ContactListItem, error) {
m.lastGetID = append([]byte(nil), userID...)
if m.getErr != nil {
return nil, m.getErr
}
return m.getItem, nil
}
func (m *mockContactRepo) IsActorAdmin(_ context.Context) (bool, error) {
if m.isActorAdminErr != nil {
return false, m.isActorAdminErr
}
return m.isActorAdmin, nil
}
func (m *mockContactRepo) ResolveRoleCodeByID(_ context.Context, roleID []byte) (string, error) {
m.lastResolveID = append([]byte(nil), roleID...)
if m.resolveRoleErr != nil {
return "", m.resolveRoleErr
}
return m.resolveRoleCode, nil
}
func (m *mockContactRepo) CreateContact(_ context.Context, in contact.CreateInput) ([]byte, error) {
m.lastCreate = in
if m.createErr != nil {
return nil, m.createErr
}
return m.createID, nil
}
func (m *mockContactRepo) UpdateContact(_ context.Context, in contact.UpdateInput) error {
m.lastUpdate = in
return m.updateErr
}
func (m *mockContactRepo) DeleteContact(_ context.Context, userID []byte) error {
m.lastDeleteID = append([]byte(nil), userID...)
return m.deleteErr
}
func (m *mockContactRepo) UpdateContactStatus(_ context.Context, userID []byte, isActive bool) error {
m.lastStatusID = append([]byte(nil), userID...)
m.lastStatusIsActive = isActive
return m.updateStatusErr
}
func (m *mockContactRepo) UpdateContactRole(_ context.Context, userID []byte, roleCode string) error {
m.lastRoleID = append([]byte(nil), userID...)
m.lastRoleCode = roleCode
return m.updateRoleErr
}
func (m *mockContactRepo) UpdatePilotCategory(_ context.Context, userID []byte, category string) error {
m.lastPilotID = append([]byte(nil), userID...)
m.lastCategory = category
return m.updatePilotErr
}
func (m *mockContactRepo) UpdateLicense(_ context.Context, userID []byte, licenseNo *string) error {
m.lastLicenseID = append([]byte(nil), userID...)
m.lastLicenseNo = licenseNo
return m.updateLicenseErr
}
func (m *mockContactRepo) UpdateChiefFlags(_ context.Context, userID []byte, patch contact.ProfilePatch) error {
m.lastChiefID = append([]byte(nil), userID...)
m.lastChiefPatch = patch
return m.updateChiefFlagsErr
}
func (m *mockContactRepo) UpdateResponsibleComplaints(_ context.Context, userID []byte, value bool) error {
m.lastResponsibleID = append([]byte(nil), userID...)
m.lastResponsibleValue = value
return m.updateResponsibleErr
}
func (m *mockContactRepo) BulkUpdateStatus(_ context.Context, userIDs [][]byte, isActive bool) (int64, error) {
m.lastBulkIDs = append([][]byte(nil), userIDs...)
m.lastBulkActive = isActive
if m.bulkErr != nil {
return 0, m.bulkErr
}
return m.bulkCount, nil
}
type mockPINVerifier struct {
err error
lastUserID []byte
lastAction string
lastToken string
}
func (m *mockPINVerifier) ConsumeSecurityPINActionToken(_ context.Context, userID []byte, action, token string) error {
m.lastUserID = append([]byte(nil), userID...)
m.lastAction = action
m.lastToken = token
return m.err
}
func TestNewContactService(t *testing.T) {
repo := &mockContactRepo{}
verifier := &mockPINVerifier{}
svc := NewContactService(repo, verifier)
if svc == nil || svc.repo != repo || svc.pinVerifier != verifier {
t.Fatalf("expected service to hold dependencies")
}
}
func TestContactServiceRepoNotConfigured(t *testing.T) {
svc := NewContactService(nil, nil)
ctx := context.Background()
userID := uuidv7.MustBytes()
if _, _, err := svc.ListContacts(ctx, contact.ListFilter{}); err == nil {
t.Fatalf("expected list error")
}
if _, err := svc.GetContactByID(ctx, userID); err == nil {
t.Fatalf("expected get error")
}
if _, err := svc.ResolveRoleCodeByID(ctx, userID); err == nil {
t.Fatalf("expected resolve role error")
}
if _, err := svc.CreateContact(ctx, contact.CreateInput{}); err == nil {
t.Fatalf("expected create error")
}
if err := svc.UpdateContact(ctx, contact.UpdateInput{}); err == nil {
t.Fatalf("expected update error")
}
if err := svc.DeleteContact(ctx, userID); err == nil {
t.Fatalf("expected delete error")
}
if err := svc.UpdateContactStatus(ctx, userID, true); err == nil {
t.Fatalf("expected status error")
}
if err := svc.UpdateContactRole(ctx, userID, "pilot"); err == nil {
t.Fatalf("expected role error")
}
if err := svc.UpdatePilotCategory(ctx, userID, "regular"); err == nil {
t.Fatalf("expected pilot category error")
}
if err := svc.UpdateLicense(ctx, userID, nil); err == nil {
t.Fatalf("expected license error")
}
if err := svc.UpdateChiefFlags(ctx, userID, contact.ProfilePatch{}); err == nil {
t.Fatalf("expected chief flags error")
}
if err := svc.UpdateResponsibleComplaints(ctx, userID, true); err == nil {
t.Fatalf("expected responsible complaints error")
}
if _, err := svc.BulkUpdateStatus(ctx, [][]byte{userID}, true); err == nil {
t.Fatalf("expected bulk update error")
}
}
func TestContactServiceDelegatesToRepo(t *testing.T) {
ctx := context.Background()
userID := uuidv7.MustBytes()
roleID := uuidv7.MustBytes()
licenseNo := "LIC"
shortName := "PJ"
repoErr := errors.New("repo failed")
t.Run("list success and error", func(t *testing.T) {
repo := &mockContactRepo{
listItems: []contact.ContactListItem{{UserID: userID, Email: "user@example.com"}},
listTotal: 1,
}
svc := NewContactService(repo, nil)
filter := contact.ListFilter{Role: "pilot", Sort: "sortkey", Limit: 10, Offset: 5}
items, total, err := svc.ListContacts(ctx, filter)
if err != nil || total != 1 || len(items) != 1 {
t.Fatalf("unexpected list result: total=%d len=%d err=%v", total, len(items), err)
}
if repo.lastFilter.Role != "pilot" || repo.lastFilter.Limit != 10 || repo.lastFilter.Offset != 5 {
t.Fatalf("expected filter forwarded, got %#v", repo.lastFilter)
}
if repo.lastFilter.Sort != "users.is_active DESC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN users.sortkey END ASC, users.first_name ASC" {
t.Fatalf("unexpected normalized sort, got %q", repo.lastFilter.Sort)
}
repo.listErr = repoErr
if _, _, err := svc.ListContacts(ctx, filter); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("get success and error", func(t *testing.T) {
repo := &mockContactRepo{getItem: &contact.ContactListItem{UserID: userID, Email: "user@example.com"}}
svc := NewContactService(repo, nil)
item, err := svc.GetContactByID(ctx, userID)
if err != nil || item == nil || string(repo.lastGetID) != string(userID) {
t.Fatalf("unexpected get result: item=%v err=%v", item, err)
}
repo.getErr = repoErr
if _, err := svc.GetContactByID(ctx, userID); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("resolve role success and error", func(t *testing.T) {
repo := &mockContactRepo{resolveRoleCode: "pilot"}
svc := NewContactService(repo, nil)
roleCode, err := svc.ResolveRoleCodeByID(ctx, roleID)
if err != nil || roleCode != "pilot" || string(repo.lastResolveID) != string(roleID) {
t.Fatalf("unexpected resolve role result: role=%q err=%v", roleCode, err)
}
repo.resolveRoleErr = repoErr
if _, err := svc.ResolveRoleCodeByID(ctx, roleID); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("create success and error", func(t *testing.T) {
repo := &mockContactRepo{createID: userID}
svc := NewContactService(repo, nil)
in := contact.CreateInput{
RoleID: roleID,
User: contact.UserPatch{Email: &licenseNo},
Profile: contact.ProfilePatch{ShortName: &shortName},
}
gotID, err := svc.CreateContact(ctx, in)
if err != nil || string(gotID) != string(userID) {
t.Fatalf("unexpected create result: id=%v err=%v", gotID, err)
}
if string(repo.lastCreate.RoleID) != string(roleID) || repo.lastCreate.Profile.ShortName == nil || *repo.lastCreate.Profile.ShortName != shortName {
t.Fatalf("expected create input forwarded")
}
repo.createErr = repoErr
if _, err := svc.CreateContact(ctx, in); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("update success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
in := contact.UpdateInput{UserID: userID, Profile: contact.ProfilePatch{ShortName: &shortName}}
if err := svc.UpdateContact(ctx, in); err != nil {
t.Fatalf("unexpected update error: %v", err)
}
if string(repo.lastUpdate.UserID) != string(userID) || repo.lastUpdate.Profile.ShortName == nil || *repo.lastUpdate.Profile.ShortName != shortName {
t.Fatalf("expected update input forwarded")
}
repo.updateErr = repoErr
if err := svc.UpdateContact(ctx, in); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("delete success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
if err := svc.DeleteContact(ctx, userID); err != nil {
t.Fatalf("unexpected delete error: %v", err)
}
if string(repo.lastDeleteID) != string(userID) {
t.Fatalf("expected delete id forwarded")
}
repo.deleteErr = repoErr
if err := svc.DeleteContact(ctx, userID); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("status success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
if err := svc.UpdateContactStatus(ctx, userID, true); err != nil {
t.Fatalf("unexpected status error: %v", err)
}
if string(repo.lastStatusID) != string(userID) || !repo.lastStatusIsActive {
t.Fatalf("expected status args forwarded")
}
repo.updateStatusErr = repoErr
if err := svc.UpdateContactStatus(ctx, userID, true); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("role success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
if err := svc.UpdateContactRole(ctx, userID, "pilot"); err != nil {
t.Fatalf("unexpected role error: %v", err)
}
if string(repo.lastRoleID) != string(userID) || repo.lastRoleCode != "pilot" {
t.Fatalf("expected role args forwarded")
}
repo.updateRoleErr = repoErr
if err := svc.UpdateContactRole(ctx, userID, "pilot"); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("pilot category success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
if err := svc.UpdatePilotCategory(ctx, userID, "regular"); err != nil {
t.Fatalf("unexpected pilot category error: %v", err)
}
if string(repo.lastPilotID) != string(userID) || repo.lastCategory != "regular" {
t.Fatalf("expected pilot category args forwarded")
}
repo.updatePilotErr = repoErr
if err := svc.UpdatePilotCategory(ctx, userID, "regular"); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("license success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
if err := svc.UpdateLicense(ctx, userID, &licenseNo); err != nil {
t.Fatalf("unexpected license error: %v", err)
}
if string(repo.lastLicenseID) != string(userID) || repo.lastLicenseNo == nil || *repo.lastLicenseNo != licenseNo {
t.Fatalf("expected license args forwarded")
}
repo.updateLicenseErr = repoErr
if err := svc.UpdateLicense(ctx, userID, &licenseNo); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("chief flags success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
patch := contact.ProfilePatch{ShortName: &shortName}
if err := svc.UpdateChiefFlags(ctx, userID, patch); err != nil {
t.Fatalf("unexpected chief flags error: %v", err)
}
if string(repo.lastChiefID) != string(userID) || repo.lastChiefPatch.ShortName == nil || *repo.lastChiefPatch.ShortName != shortName {
t.Fatalf("expected chief flags args forwarded")
}
repo.updateChiefFlagsErr = repoErr
if err := svc.UpdateChiefFlags(ctx, userID, patch); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("responsible complaints success and error", func(t *testing.T) {
repo := &mockContactRepo{}
svc := NewContactService(repo, nil)
if err := svc.UpdateResponsibleComplaints(ctx, userID, true); err != nil {
t.Fatalf("unexpected responsible complaints error: %v", err)
}
if string(repo.lastResponsibleID) != string(userID) || !repo.lastResponsibleValue {
t.Fatalf("expected responsible complaints args forwarded")
}
repo.updateResponsibleErr = repoErr
if err := svc.UpdateResponsibleComplaints(ctx, userID, true); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
t.Run("bulk update success and error", func(t *testing.T) {
repo := &mockContactRepo{bulkCount: 2}
svc := NewContactService(repo, nil)
ids := [][]byte{userID, uuidv7.MustBytes()}
count, err := svc.BulkUpdateStatus(ctx, ids, true)
if err != nil || count != 2 {
t.Fatalf("unexpected bulk update result: count=%d err=%v", count, err)
}
if len(repo.lastBulkIDs) != 2 || !repo.lastBulkActive {
t.Fatalf("expected bulk update args forwarded")
}
repo.bulkErr = repoErr
if _, err := svc.BulkUpdateStatus(ctx, ids, true); !errors.Is(err, repoErr) {
t.Fatalf("expected repo error, got %v", err)
}
})
}
func TestNormalizeContactSort(t *testing.T) {
cases := map[string]string{
"sortkey": "users.is_active DESC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN users.sortkey END ASC, users.first_name ASC",
"-sortkey": "users.is_active DESC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN users.is_active = 1 AND users.sortkey IS NOT NULL AND users.sortkey >= 0 THEN users.sortkey END DESC, users.first_name ASC",
"note": "COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') ASC",
"-note": "COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') DESC",
"is_active": "users.is_active ASC",
"-is_active": "users.is_active DESC",
"name": "users.first_name ASC",
"email": "users.email ASC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeContactSort(in); got != want {
t.Fatalf("normalizeContactSort(%q) = %q, want %q", in, got, want)
}
}
}
func TestContactServiceCheckSensitiveAction(t *testing.T) {
ctx := context.Background()
validUserID := uuidv7.MustBytes()
verifierErr := errors.New("pin verify failed")
svc := NewContactService(&mockContactRepo{}, nil)
if err := svc.CheckSensitiveAction(ctx, []byte("short"), "token", "action"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid pin action for invalid user id, got %v", err)
}
if err := svc.CheckSensitiveAction(ctx, validUserID, "token", "action"); err == nil || err.Error() != "pin verifier not configured" {
t.Fatalf("expected pin verifier not configured error, got %v", err)
}
verifier := &mockPINVerifier{}
svc = NewContactService(&mockContactRepo{}, verifier)
if err := svc.CheckSensitiveAction(ctx, validUserID, " ", "action"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid pin action for empty token, got %v", err)
}
if err := svc.CheckSensitiveAction(ctx, validUserID, "token", " "); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid pin action for empty action, got %v", err)
}
verifier.err = verifierErr
if err := svc.CheckSensitiveAction(ctx, validUserID, " token ", " action "); !errors.Is(err, verifierErr) {
t.Fatalf("expected verifier error, got %v", err)
}
if string(verifier.lastUserID) != string(validUserID) || verifier.lastAction != "action" || verifier.lastToken != "token" {
t.Fatalf("expected trimmed action/token forwarded")
}
verifier.err = nil
if err := svc.CheckSensitiveAction(ctx, validUserID, " token ", " action "); err != nil {
t.Fatalf("unexpected verifier error: %v", err)
}
}

View File

@@ -0,0 +1,29 @@
package service
import (
"strings"
)
// CookieValues returns all values for the given cookie name from a raw Cookie header.
// It preserves header order so callers can try candidates until one validates.
func CookieValues(rawHeader, cookieName string) []string {
rawHeader = strings.TrimSpace(rawHeader)
cookieName = strings.TrimSpace(cookieName)
if rawHeader == "" || cookieName == "" {
return nil
}
prefix := cookieName + "="
values := make([]string, 0, 2)
for _, part := range strings.Split(rawHeader, ";") {
part = strings.TrimSpace(part)
if !strings.HasPrefix(part, prefix) {
continue
}
value := strings.TrimSpace(strings.TrimPrefix(part, prefix))
if value != "" {
values = append(values, value)
}
}
return values
}

View File

@@ -0,0 +1,14 @@
package service
import "testing"
func TestCookieValues(t *testing.T) {
raw := "foo=1; wucher_at=old-token; bar=2; wucher_at=new-token"
values := CookieValues(raw, "wucher_at")
if len(values) != 2 {
t.Fatalf("expected 2 cookie values, got %d", len(values))
}
if values[0] != "old-token" || values[1] != "new-token" {
t.Fatalf("unexpected cookie order: %#v", values)
}
}

View File

@@ -0,0 +1,61 @@
package service
import (
"context"
"time"
"wucher/internal/domain/dul"
)
type DULService struct {
repo dul.Repository
}
func NewDULService(repo dul.Repository) *DULService {
return &DULService{repo: repo}
}
func (s *DULService) Create(ctx context.Context, row *dul.DUL) error {
return s.repo.Create(ctx, row)
}
func (s *DULService) Update(ctx context.Context, row *dul.DUL) error {
return s.repo.Update(ctx, row)
}
func (s *DULService) Delete(ctx context.Context, id []byte, actorID []byte) error {
row, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if row == nil {
return nil
}
row.DeletedAt = ptrTimeDUL(time.Now().UTC())
row.DeletedBy = actorID
return s.repo.Delete(ctx, id)
}
func (s *DULService) GetByID(ctx context.Context, id []byte) (*dul.DUL, error) {
return s.repo.GetByID(ctx, id)
}
func (s *DULService) List(ctx context.Context, filter, sort string, limit, offset int, baseIDs [][]byte) ([]dul.DUL, int64, error) {
return s.repo.List(ctx, filter, sort, limit, offset, baseIDs)
}
func (s *DULService) WithTransaction(ctx context.Context, fn func(dul.Service) error) error {
if s == nil || fn == nil {
return nil
}
if txRepo, ok := s.repo.(dul.TransactionalRepository); ok {
return txRepo.WithTransaction(ctx, func(repo dul.Repository) error {
return fn(&DULService{repo: repo})
})
}
return fn(s)
}
func ptrTimeDUL(t time.Time) *time.Time {
return &t
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
package service
import (
"context"
"time"
easarelease "wucher/internal/domain/easa_release"
)
type EASAReleaseService struct{ repo easarelease.Repository }
func NewEASAReleaseService(repo easarelease.Repository) *EASAReleaseService {
return &EASAReleaseService{repo: repo}
}
func (s *EASAReleaseService) Create(ctx context.Context, row *easarelease.EASARelease) error {
return s.repo.Create(ctx, row)
}
func (s *EASAReleaseService) Update(ctx context.Context, row *easarelease.EASARelease) error {
return s.repo.Update(ctx, row)
}
func (s *EASAReleaseService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *EASAReleaseService) GetByID(ctx context.Context, id []byte) (*easarelease.EASARelease, error) {
return s.repo.GetByID(ctx, id)
}
// GetByFlightID returns the current (newest non-voided) release for a flight.
func (s *EASAReleaseService) GetByFlightID(ctx context.Context, flightID []byte) (*easarelease.EASARelease, error) {
return s.repo.GetByFlightID(ctx, flightID)
}
// GetByComplaintID returns the current (newest non-voided) release for a complaint.
func (s *EASAReleaseService) GetByComplaintID(ctx context.Context, complaintID []byte) (*easarelease.EASARelease, error) {
return s.repo.GetByComplaintID(ctx, complaintID)
}
func (s *EASAReleaseService) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]easarelease.EASARelease, error) {
return s.repo.ListByHelicopter(ctx, helicopterID)
}
func (s *EASAReleaseService) GetLatestByHelicopter(ctx context.Context, helicopterID []byte) (*easarelease.EASARelease, error) {
return s.repo.GetLatestByHelicopter(ctx, helicopterID)
}
func (s *EASAReleaseService) GetActiveByHelicopter(ctx context.Context, helicopterID []byte) (*easarelease.EASARelease, error) {
return s.repo.GetActiveByHelicopter(ctx, helicopterID)
}
// Sign stamps the maintenance release as signed (active). A signed release closes
// the defects of its flight inspection. Re-signing an already-signed release fails.
func (s *EASAReleaseService) Sign(ctx context.Context, id []byte, signerID []byte) error {
row, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if row == nil {
return easarelease.ErrNotFound
}
if row.SignedAt != nil {
return easarelease.ErrAlreadySigned
}
if len(row.MissingSignFields()) > 0 {
return easarelease.ErrIncompleteForSign
}
now := time.Now().UTC()
row.SignedAt = &now
row.SignedBy = append([]byte(nil), signerID...)
row.UpdatedBy = append([]byte(nil), signerID...)
return s.repo.Update(ctx, row)
}
// Amend supersedes the current release: it voids (soft-deletes) the current one and
// creates a fresh unsigned copy for the same flight inspection, returning the new id.
func (s *EASAReleaseService) Amend(ctx context.Context, currentID []byte, reason string, actor []byte) ([]byte, error) {
cur, err := s.repo.GetByID(ctx, currentID)
if err != nil {
return nil, err
}
if cur == nil {
return nil, easarelease.ErrNotFound
}
if err := s.repo.VoidByID(ctx, currentID, reason, actor); err != nil {
return nil, err
}
next := &easarelease.EASARelease{
HelicopterID: append([]byte(nil), cur.HelicopterID...),
FlightID: append([]byte(nil), cur.FlightID...),
ComplaintID: append([]byte(nil), cur.ComplaintID...),
EASADate: cur.EASADate,
EASAACHours: cur.EASAACHours,
EASALocation: cur.EASALocation,
EASAWONo: cur.EASAWONo,
EASAMaintManualRevAirframe: cur.EASAMaintManualRevAirframe,
EASAMaintManualRevEngine: cur.EASAMaintManualRevEngine,
EASASignerID: cur.EASASignerID,
EASAPermitNo: cur.EASAPermitNo,
CreatedBy: append([]byte(nil), actor...),
UpdatedBy: append([]byte(nil), actor...),
}
if err := s.repo.Create(ctx, next); err != nil {
return nil, err
}
return next.ID, nil
}

View File

@@ -0,0 +1,173 @@
package service
import (
"context"
"errors"
"testing"
"time"
easarelease "wucher/internal/domain/easa_release"
)
type stubEASARepo struct {
row *easarelease.EASARelease
updated *easarelease.EASARelease
created *easarelease.EASARelease
voidedID []byte
createErr error
getErr error
voidErr error
}
func (s *stubEASARepo) Create(_ context.Context, row *easarelease.EASARelease) error {
s.created = row
if len(row.ID) == 0 {
row.ID = []byte("newid0123456789a")
}
return s.createErr
}
func (s *stubEASARepo) Update(_ context.Context, row *easarelease.EASARelease) error {
s.updated = row
return nil
}
func (s *stubEASARepo) Delete(_ context.Context, _, _ []byte) error { return nil }
func (s *stubEASARepo) GetByID(_ context.Context, _ []byte) (*easarelease.EASARelease, error) {
return s.row, s.getErr
}
func (s *stubEASARepo) GetByFlightID(_ context.Context, _ []byte) (*easarelease.EASARelease, error) {
return s.row, s.getErr
}
func (s *stubEASARepo) GetByComplaintID(_ context.Context, _ []byte) (*easarelease.EASARelease, error) {
return s.row, s.getErr
}
func (s *stubEASARepo) ListByHelicopter(_ context.Context, _ []byte) ([]easarelease.EASARelease, error) {
if s.row != nil {
return []easarelease.EASARelease{*s.row}, nil
}
return nil, nil
}
func (s *stubEASARepo) GetLatestByHelicopter(_ context.Context, _ []byte) (*easarelease.EASARelease, error) {
return s.row, nil
}
func (s *stubEASARepo) GetActiveByHelicopter(_ context.Context, _ []byte) (*easarelease.EASARelease, error) {
return s.row, nil
}
func (s *stubEASARepo) VoidByID(_ context.Context, id []byte, _ string, _ []byte) error {
s.voidedID = id
return s.voidErr
}
func completeUnsigned() *easarelease.EASARelease {
now := time.Now()
return &easarelease.EASARelease{
ID: []byte("0123456789abcdef"),
HelicopterID: []byte("fedcba9876543210"),
EASADate: &now,
EASAACHours: ptr("1245.3"),
EASALocation: ptr("LOWI"),
EASAWONo: ptr("WO-1"),
EASASignerID: ptr("019d61db-53c6-7280-87ec-e65f205c6d3b"),
}
}
func TestEASAReleaseServiceSign(t *testing.T) {
actor := []byte("aaaaaaaaaaaaaaaa")
ctx := context.Background()
if err := NewEASAReleaseService(&stubEASARepo{row: nil}).Sign(ctx, []byte("x"), actor); !errors.Is(err, easarelease.ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
incompleteRepo := &stubEASARepo{row: &easarelease.EASARelease{ID: []byte("0123456789abcdef")}}
if err := NewEASAReleaseService(incompleteRepo).Sign(ctx, incompleteRepo.row.ID, actor); !errors.Is(err, easarelease.ErrIncompleteForSign) {
t.Fatalf("want ErrIncompleteForSign, got %v", err)
}
if incompleteRepo.updated != nil {
t.Fatal("incomplete release must not be persisted signed")
}
signed := completeUnsigned()
at := time.Now()
signed.SignedAt = &at
if err := NewEASAReleaseService(&stubEASARepo{row: signed}).Sign(ctx, signed.ID, actor); !errors.Is(err, easarelease.ErrAlreadySigned) {
t.Fatalf("want ErrAlreadySigned, got %v", err)
}
okRepo := &stubEASARepo{row: completeUnsigned()}
if err := NewEASAReleaseService(okRepo).Sign(ctx, okRepo.row.ID, actor); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if okRepo.updated == nil || okRepo.updated.SignedAt == nil || string(okRepo.updated.SignedBy) != string(actor) {
t.Fatal("complete release should be persisted signed by actor")
}
}
func TestEASAReleaseServiceAmendAndDelegation(t *testing.T) {
ctx := context.Background()
repo := &stubEASARepo{row: completeUnsigned()}
svc := NewEASAReleaseService(repo)
newID, err := svc.Amend(ctx, repo.row.ID, "superseded", []byte("actor"))
if err != nil {
t.Fatalf("Amend err: %v", err)
}
if string(repo.voidedID) != string(repo.row.ID) {
t.Fatal("Amend should void the current release")
}
if repo.created == nil || string(repo.created.HelicopterID) != string(repo.row.HelicopterID) {
t.Fatal("Amend should create a copy for the same helicopter")
}
if len(newID) == 0 {
t.Fatal("Amend should return the new id")
}
if _, err := svc.GetLatestByHelicopter(ctx, []byte("h")); err != nil {
t.Fatalf("GetLatestByHelicopter: %v", err)
}
if _, err := svc.GetActiveByHelicopter(ctx, []byte("h")); err != nil {
t.Fatalf("GetActiveByHelicopter: %v", err)
}
if rows, err := svc.ListByHelicopter(ctx, []byte("h")); err != nil || len(rows) != 1 {
t.Fatalf("ListByHelicopter: %v", err)
}
if err := svc.Delete(ctx, []byte("x"), nil); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := svc.GetByID(ctx, []byte("x")); err != nil {
t.Fatalf("GetByID: %v", err)
}
// Amend on missing release.
if _, err := NewEASAReleaseService(&stubEASARepo{row: nil}).Amend(ctx, []byte("x"), "r", nil); !errors.Is(err, easarelease.ErrNotFound) {
t.Fatalf("Amend missing: want ErrNotFound, got %v", err)
}
// Amend with void error.
if _, err := NewEASAReleaseService(&stubEASARepo{row: completeUnsigned(), voidErr: errors.New("boom")}).Amend(ctx, []byte("x"), "r", nil); err == nil {
t.Fatal("Amend should propagate void error")
}
// Amend with create error.
if _, err := NewEASAReleaseService(&stubEASARepo{row: completeUnsigned(), createErr: errors.New("boom")}).Amend(ctx, []byte("x"), "r", nil); err == nil {
t.Fatal("Amend should propagate create error")
}
// Amend with GetByID error.
if _, err := NewEASAReleaseService(&stubEASARepo{getErr: errors.New("boom")}).Amend(ctx, []byte("x"), "r", nil); err == nil {
t.Fatal("Amend should propagate GetByID error")
}
}
func TestEASAReleaseServiceCreateUpdateAndErrors(t *testing.T) {
ctx := context.Background()
repo := &stubEASARepo{}
svc := NewEASAReleaseService(repo)
row := completeUnsigned()
if err := svc.Create(ctx, row); err != nil || repo.created != row {
t.Fatalf("Create not delegated: %v", err)
}
if err := svc.Update(ctx, row); err != nil || repo.updated != row {
t.Fatalf("Update not delegated: %v", err)
}
// Sign with GetByID error.
if err := NewEASAReleaseService(&stubEASARepo{getErr: errors.New("boom")}).Sign(ctx, []byte("x"), nil); err == nil {
t.Fatal("Sign should propagate GetByID error")
}
}

View File

@@ -0,0 +1,119 @@
package service
import (
"context"
"errors"
"log/slog"
"net"
"strings"
"github.com/aws/smithy-go"
"wucher/internal/queue"
)
type EmailDeliveryHandler struct {
sender EmailSender
logger *slog.Logger
}
func NewEmailDeliveryHandler(sender EmailSender, logger *slog.Logger) *EmailDeliveryHandler {
if logger == nil {
logger = slog.Default()
}
return &EmailDeliveryHandler{
sender: sender,
logger: logger,
}
}
func (h *EmailDeliveryHandler) Handle(ctx context.Context, envelope *queue.Envelope) error {
if h.sender == nil {
return queue.Permanent(errors.New("email sender is required"))
}
if envelope == nil {
return queue.Permanent(errors.New("email envelope is required"))
}
if err := envelope.Payload.Validate(); err != nil {
return queue.Permanent(err)
}
job := envelope.Payload
shouldSkipSend := strings.EqualFold(strings.TrimSpace(job.Metadata["skip_send"]), "true")
// Defense-in-depth: register verification emails should never be delivered.
if !shouldSkipSend &&
strings.EqualFold(strings.TrimSpace(job.MessageType), "email_verify") &&
strings.EqualFold(strings.TrimSpace(job.Tags["source"]), "register") {
shouldSkipSend = true
}
if shouldSkipSend {
h.logger.Info("email delivery skipped",
slog.String("message_id", envelope.MessageID),
slog.String("job_type", job.NormalizedType()),
slog.String("recipient_hash", job.RecipientHash()),
slog.String("correlation_id", envelope.CorrelationID),
)
return nil
}
result, err := h.sender.Send(ctx, job)
if err != nil {
classified := classifyDeliveryError(err)
h.logger.Error("email delivery failed",
slog.String("message_id", envelope.MessageID),
slog.String("job_type", job.NormalizedType()),
slog.String("recipient_hash", job.RecipientHash()),
slog.String("correlation_id", envelope.CorrelationID),
slog.Any("error", err),
)
return classified
}
h.logger.Info("email delivered",
slog.String("message_id", envelope.MessageID),
slog.String("job_type", job.NormalizedType()),
slog.String("recipient_hash", job.RecipientHash()),
slog.String("correlation_id", envelope.CorrelationID),
slog.String("provider", result.Provider),
slog.String("provider_message_id", result.ProviderMessageID),
slog.String("provider_request_id", result.RequestID),
)
return nil
}
func classifyDeliveryError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return queue.Transient(err)
}
var netErr net.Error
if errors.As(err, &netErr) {
return queue.Transient(err)
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
code := strings.ToLower(strings.TrimSpace(apiErr.ErrorCode()))
switch code {
case "throttling", "throttlingexception", "toomanyrequestsexception", "serviceunavailableexception", "internalserviceerror", "sendingpausedexception":
return queue.Transient(err)
case "message_rejected", "messagerejected", "badrequestexception", "mailfromdomainnotverifiedexception", "configurationsetdoesnotexistexception", "notfoundexception":
return queue.Permanent(err)
}
}
message := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(message, "recipient is required"):
return queue.Permanent(err)
case strings.Contains(message, "subject is required"):
return queue.Permanent(err)
case strings.Contains(message, "body is required"):
return queue.Permanent(err)
case strings.Contains(message, "invalid email job"):
return queue.Permanent(err)
case strings.Contains(message, "ses is not configured"):
return queue.Permanent(err)
default:
return queue.Transient(err)
}
}

View File

@@ -0,0 +1,93 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/queue"
)
type emailSenderStub struct {
sendFn func(ctx context.Context, job EmailJob) (EmailDeliveryResult, error)
}
func (s emailSenderStub) Send(ctx context.Context, job EmailJob) (EmailDeliveryResult, error) {
if s.sendFn != nil {
return s.sendFn(ctx, job)
}
return EmailDeliveryResult{}, nil
}
func TestEmailDeliveryHandler_Handle_SkipSend(t *testing.T) {
called := false
handler := NewEmailDeliveryHandler(emailSenderStub{
sendFn: func(context.Context, EmailJob) (EmailDeliveryResult, error) {
called = true
return EmailDeliveryResult{}, nil
},
}, nil)
err := handler.Handle(context.Background(), &queue.Envelope{
MessageID: "msg-1",
Payload: EmailJob{
To: "user@example.com",
Subject: "Verify",
TextBody: "Body",
Metadata: map[string]string{
"skip_send": "true",
},
},
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if called {
t.Fatalf("expected sender not called when skip_send=true")
}
}
func TestEmailDeliveryHandler_Handle_SendWhenNotSkipped(t *testing.T) {
called := false
handler := NewEmailDeliveryHandler(emailSenderStub{
sendFn: func(context.Context, EmailJob) (EmailDeliveryResult, error) {
called = true
return EmailDeliveryResult{}, nil
},
}, nil)
err := handler.Handle(context.Background(), &queue.Envelope{
MessageID: "msg-2",
Payload: EmailJob{
To: "user@example.com",
Subject: "Verify",
TextBody: "Body",
},
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if !called {
t.Fatalf("expected sender called when skip_send is absent")
}
}
func TestEmailDeliveryHandler_Handle_ClassifyErrorWhenNotSkipped(t *testing.T) {
handler := NewEmailDeliveryHandler(emailSenderStub{
sendFn: func(context.Context, EmailJob) (EmailDeliveryResult, error) {
return EmailDeliveryResult{}, errors.New("network timeout")
},
}, nil)
err := handler.Handle(context.Background(), &queue.Envelope{
MessageID: "msg-3",
Payload: EmailJob{
To: "user@example.com",
Subject: "Verify",
TextBody: "Body",
},
})
if err == nil {
t.Fatalf("expected classified error")
}
}

View File

@@ -0,0 +1,43 @@
package service
import (
"context"
"encoding/json"
"wucher/internal/queue"
)
type EmailJob = queue.EmailJob
type EmailQueue = queue.EmailJobProducer
func EncodeEmailJob(job EmailJob) ([]byte, error) {
return json.Marshal(job.Canonical())
}
func DecodeEmailJob(b []byte) (*EmailJob, error) {
var job EmailJob
if err := json.Unmarshal(b, &job); err != nil {
return nil, err
}
job = job.Canonical()
if err := job.Validate(); err != nil {
return nil, err
}
return &job, nil
}
type emailQueueAdapter struct {
producer queue.EmailJobProducer
}
func (a emailQueueAdapter) Enqueue(ctx context.Context, job EmailJob) error {
return a.producer.Enqueue(ctx, job)
}
func AdaptEmailQueue(producer queue.EmailJobProducer) EmailQueue {
if producer == nil {
return nil
}
return emailQueueAdapter{producer: producer}
}

View File

@@ -0,0 +1,38 @@
package service
import "testing"
func TestEncodeEmailJob(t *testing.T) {
data, err := EncodeEmailJob(EmailJob{
To: "user@example.com",
Subject: "Welcome",
Body: "Hello",
})
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(data) == 0 {
t.Fatalf("expected encoded payload")
}
}
func TestDecodeEmailJob(t *testing.T) {
t.Run("success", func(t *testing.T) {
job, err := DecodeEmailJob([]byte(`{"to":"user@example.com","subject":"Welcome","body":"Hello"}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if job == nil {
t.Fatalf("expected email job")
}
if job.To != "user@example.com" || job.Subject != "Welcome" || job.TextBody != "Hello" {
t.Fatalf("unexpected decoded value: %+v", job)
}
})
t.Run("invalid json", func(t *testing.T) {
if _, err := DecodeEmailJob([]byte(`{"to":`)); err == nil {
t.Fatalf("expected decode error")
}
})
}

View File

@@ -0,0 +1,138 @@
package service
import (
"bytes"
"encoding/base64"
"fmt"
"mime/quotedprintable"
"strings"
sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
"wucher/internal/queue"
"wucher/internal/shared/pkg/uuidv7"
)
func buildSESRawContent(from string, to, cc, replyTo []string, job queue.EmailJob) (*sestypes.EmailContent, error) {
mixedBoundary := randomMIMEBoundary("mixed")
relatedBoundary := randomMIMEBoundary("related")
alternativeBoundary := randomMIMEBoundary("alt")
var buf bytes.Buffer
writeMIMEHeader(&buf, "From", from)
if len(to) > 0 {
writeMIMEHeader(&buf, "To", strings.Join(to, ", "))
}
if len(cc) > 0 {
writeMIMEHeader(&buf, "Cc", strings.Join(cc, ", "))
}
if len(replyTo) > 0 {
writeMIMEHeader(&buf, "Reply-To", strings.Join(replyTo, ", "))
}
writeMIMEHeader(&buf, "Subject", sanitizeSubject(job.Subject))
writeMIMEHeader(&buf, "MIME-Version", "1.0")
writeMIMEHeader(&buf, "Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mixedBoundary))
buf.WriteString("\r\n")
writeBoundary(&buf, mixedBoundary)
writeMIMEHeader(&buf, "Content-Type", fmt.Sprintf(`multipart/related; boundary="%s"`, relatedBoundary))
buf.WriteString("\r\n")
writeBoundary(&buf, relatedBoundary)
writeMIMEHeader(&buf, "Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, alternativeBoundary))
buf.WriteString("\r\n")
if textBody := strings.TrimSpace(job.TextBodyValue()); textBody != "" {
writeBoundary(&buf, alternativeBoundary)
writeTextPart(&buf, "text/plain", textBody)
}
if htmlBody := strings.TrimSpace(job.HTMLBody); htmlBody != "" {
writeBoundary(&buf, alternativeBoundary)
writeTextPart(&buf, "text/html", htmlBody)
}
writeClosingBoundary(&buf, alternativeBoundary)
for i := range job.Attachments {
writeBoundary(&buf, relatedBoundary)
writeAttachmentPart(&buf, job.Attachments[i])
}
writeClosingBoundary(&buf, relatedBoundary)
writeClosingBoundary(&buf, mixedBoundary)
return &sestypes.EmailContent{
Raw: &sestypes.RawMessage{Data: buf.Bytes()},
}, nil
}
func writeTextPart(buf *bytes.Buffer, contentType, body string) {
writeMIMEHeader(buf, "Content-Type", contentType+`; charset="UTF-8"`)
writeMIMEHeader(buf, "Content-Transfer-Encoding", "quoted-printable")
buf.WriteString("\r\n")
qp := quotedprintable.NewWriter(buf)
_, _ = qp.Write([]byte(body))
_ = qp.Close()
buf.WriteString("\r\n")
}
func writeAttachmentPart(buf *bytes.Buffer, attachment queue.EmailAttachment) {
filename := attachment.Filename
if filename == "" {
filename = "attachment"
}
writeMIMEHeader(buf, "Content-Type", fmt.Sprintf(`%s; name="%s"`, attachment.ContentType, escapeMIMEHeaderValue(filename)))
writeMIMEHeader(buf, "Content-Transfer-Encoding", "base64")
disposition := "attachment"
if attachment.Inline {
disposition = "inline"
}
writeMIMEHeader(buf, "Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, escapeMIMEHeaderValue(filename)))
if attachment.Inline && strings.TrimSpace(attachment.ContentID) != "" {
writeMIMEHeader(buf, "Content-ID", "<"+strings.TrimSpace(attachment.ContentID)+">")
}
buf.WriteString("\r\n")
writeBase64Lines(buf, attachment.Content)
}
func writeBase64Lines(buf *bytes.Buffer, payload []byte) {
encoded := base64.StdEncoding.EncodeToString(payload)
for len(encoded) > 76 {
buf.WriteString(encoded[:76])
buf.WriteString("\r\n")
encoded = encoded[76:]
}
if encoded != "" {
buf.WriteString(encoded)
buf.WriteString("\r\n")
}
}
func writeMIMEHeader(buf *bytes.Buffer, key, value string) {
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(value)
buf.WriteString("\r\n")
}
func writeBoundary(buf *bytes.Buffer, boundary string) {
buf.WriteString("--")
buf.WriteString(boundary)
buf.WriteString("\r\n")
}
func writeClosingBoundary(buf *bytes.Buffer, boundary string) {
buf.WriteString("--")
buf.WriteString(boundary)
buf.WriteString("--\r\n")
}
func randomMIMEBoundary(prefix string) string {
id, err := uuidv7.String(uuidv7.MustNew())
if err != nil {
return prefix + "_fallback"
}
return prefix + "_" + strings.ReplaceAll(id, "-", "")
}
func escapeMIMEHeaderValue(value string) string {
return strings.ReplaceAll(strings.TrimSpace(value), `"`, `'`)
}

View File

@@ -0,0 +1,17 @@
package service
import (
"context"
"wucher/internal/queue"
)
type EmailDeliveryResult struct {
Provider string
ProviderMessageID string
RequestID string
}
type EmailSender interface {
Send(ctx context.Context, job queue.EmailJob) (EmailDeliveryResult, error)
}

View File

@@ -0,0 +1,52 @@
package service
import (
"context"
"wucher/internal/domain/facility"
"wucher/internal/shared/pkg/sortkey"
)
type FacilityService struct {
repo facility.Repository
}
func NewFacilityService(repo facility.Repository) *FacilityService {
return &FacilityService{repo: repo}
}
func (s *FacilityService) Create(ctx context.Context, f *facility.Facility) error {
return s.repo.Create(ctx, f)
}
func (s *FacilityService) Update(ctx context.Context, f *facility.Facility) error {
return s.repo.Update(ctx, f)
}
func (s *FacilityService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *FacilityService) GetByID(ctx context.Context, id []byte) (*facility.Facility, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FacilityService) List(ctx context.Context, filter, category, sort string, limit, offset int) ([]facility.Facility, int64, error) {
return s.repo.List(ctx, filter, category, normalizeFacilitySort(sort), limit, offset)
}
func normalizeFacilitySort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("category"),
sortField("name"),
sortField("type"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,227 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/facility"
)
type facilityRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *facility.Facility
lastUpdate *facility.Facility
lastDeleteID []byte
lastGetByID []byte
lastListFilter string
lastListCategory string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *facility.Facility
listResult []facility.Facility
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *facilityRepoMock) Create(_ context.Context, row *facility.Facility) error {
m.createCalled = true
m.lastCreate = row
return m.createErr
}
func (m *facilityRepoMock) Update(_ context.Context, row *facility.Facility) error {
m.updateCalled = true
m.lastUpdate = row
return m.updateErr
}
func (m *facilityRepoMock) Delete(_ context.Context, id []byte, _ []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
return m.deleteErr
}
func (m *facilityRepoMock) GetByID(_ context.Context, id []byte) (*facility.Facility, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *facilityRepoMock) List(_ context.Context, filter, category, sort string, limit, offset int) ([]facility.Facility, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListCategory = category
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewFacilityService(t *testing.T) {
repo := &facilityRepoMock{}
svc := NewFacilityService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestFacilityServiceCreate(t *testing.T) {
repo := &facilityRepoMock{}
svc := NewFacilityService(repo)
row := &facility.Facility{Category: "HEMS", Name: "HEC Sling", Type: "Sling"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFacilityServiceUpdate(t *testing.T) {
repo := &facilityRepoMock{}
svc := NewFacilityService(repo)
row := &facility.Facility{Category: "HEMS", Name: "HEC Sling", Type: "Sling"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFacilityServiceDelete(t *testing.T) {
repo := &facilityRepoMock{}
svc := NewFacilityService(repo)
id := []byte("id")
if err := svc.Delete(context.Background(), id, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) {
t.Fatalf("expected delete id forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, nil); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFacilityServiceGetByID(t *testing.T) {
expected := &facility.Facility{Category: "HEMS", Name: "HEC Sling", Type: "Sling"}
repo := &facilityRepoMock{getByIDResult: expected}
svc := NewFacilityService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFacilityServiceList(t *testing.T) {
repo := &facilityRepoMock{
listResult: []facility.Facility{{Category: "HEMS", Name: "HEC Sling", Type: "Sling"}},
listTotal: 1,
}
svc := NewFacilityService(repo)
rows, total, err := svc.List(context.Background(), "find", "HEMS", "-name", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListCategory != "HEMS" || repo.lastListSort != "name DESC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "HEMS", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
if repo.lastListSort != "" {
t.Fatalf("expected unknown sort normalized to empty")
}
}
func TestNormalizeFacilitySort(t *testing.T) {
cases := map[string]string{
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, name ASC",
"category": "category ASC",
"-category": "category DESC",
"name": "name ASC",
"-name": "name DESC",
"type": "type ASC",
"-type": "type DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeFacilitySort(in); got != want {
t.Fatalf("normalizeFacilitySort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,51 @@
package service
import (
"context"
"wucher/internal/domain/federal_state"
"wucher/internal/shared/pkg/sortkey"
)
type FederalStateService struct {
repo federal_state.Repository
}
func NewFederalStateService(repo federal_state.Repository) *FederalStateService {
return &FederalStateService{repo: repo}
}
func (s *FederalStateService) Create(ctx context.Context, row *federal_state.FederalState) error {
return s.repo.Create(ctx, row)
}
func (s *FederalStateService) Update(ctx context.Context, row *federal_state.FederalState) error {
return s.repo.Update(ctx, row)
}
func (s *FederalStateService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *FederalStateService) GetByID(ctx context.Context, id []byte) (*federal_state.FederalState, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FederalStateService) List(ctx context.Context, filter, sort string, limit, offset int, landID []byte, landName string) ([]federal_state.FederalState, int64, error) {
return s.repo.List(ctx, filter, normalizeFederalStateSort(sort), limit, offset, landID, landName)
}
func normalizeFederalStateSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("name"),
sortField("sortkey", "sortkey", "sort_key"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,217 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/federal_state"
)
type federalStateRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *federal_state.FederalState
lastUpdate *federal_state.FederalState
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
lastListLandID []byte
lastLandName string
getByIDResult *federal_state.FederalState
listResult []federal_state.FederalState
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *federalStateRepoMock) Create(_ context.Context, o *federal_state.FederalState) error {
m.createCalled = true
m.lastCreate = o
return m.createErr
}
func (m *federalStateRepoMock) Update(_ context.Context, o *federal_state.FederalState) error {
m.updateCalled = true
m.lastUpdate = o
return m.updateErr
}
func (m *federalStateRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *federalStateRepoMock) GetByID(_ context.Context, id []byte) (*federal_state.FederalState, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *federalStateRepoMock) List(_ context.Context, filter, sort string, limit, offset int, landID []byte, landName string) ([]federal_state.FederalState, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
m.lastListLandID = append([]byte(nil), landID...)
m.lastLandName = landName
return m.listResult, m.listTotal, m.listErr
}
func TestNewFederalStateService(t *testing.T) {
repo := &federalStateRepoMock{}
svc := NewFederalStateService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestFederalStateServiceCreate(t *testing.T) {
repo := &federalStateRepoMock{}
svc := NewFederalStateService(repo)
row := &federal_state.FederalState{Name: "Name"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFederalStateServiceUpdate(t *testing.T) {
repo := &federalStateRepoMock{}
svc := NewFederalStateService(repo)
row := &federal_state.FederalState{Name: "Updated"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFederalStateServiceDelete(t *testing.T) {
repo := &federalStateRepoMock{}
svc := NewFederalStateService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFederalStateServiceGetByID(t *testing.T) {
expected := &federal_state.FederalState{Name: "A"}
repo := &federalStateRepoMock{getByIDResult: expected}
svc := NewFederalStateService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestFederalStateServiceList(t *testing.T) {
repo := &federalStateRepoMock{
listResult: []federal_state.FederalState{{Name: "A"}},
listTotal: 1,
}
svc := NewFederalStateService(repo)
landID := []byte("land")
rows, total, err := svc.List(context.Background(), "find", "sortkey", 10, 20, landID, "Germany")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 || string(repo.lastListLandID) != string(landID) || repo.lastLandName != "Germany" {
t.Fatalf("unexpected list args forwarded")
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0, nil, ""); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeFederalStateSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, name ASC",
"sort_key": "sortkey ASC",
"-sort_key": "sortkey DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeFederalStateSort(in); got != want {
t.Fatalf("normalizeFederalStateSort(%q) = %q, want %q", in, got, want)
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
package service
import (
"context"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/queue"
)
type fileProcessingQueueAdapter struct {
producer queue.FileProcessingJobProducer
}
func (a fileProcessingQueueAdapter) Enqueue(ctx context.Context, job filemanager.FileProcessingJob) error {
return a.producer.Enqueue(ctx, queue.FileProcessingJob{
JobID: job.JobID,
FileUUID: job.FileUUID,
Bucket: job.Bucket,
ObjectKey: job.ObjectKey,
UploadedBy: job.UploadedBy,
MimeType: job.MimeType,
SizeBytes: job.SizeBytes,
TraceID: job.TraceID,
RequestID: job.RequestID,
})
}
func AdaptFileProcessingQueue(producer queue.FileProcessingJobProducer) filemanager.FileProcessingQueue {
if producer == nil {
return nil
}
return fileProcessingQueueAdapter{producer: producer}
}

View File

@@ -0,0 +1,769 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/jpeg"
"image/png"
_ "image/png"
"io"
"log/slog"
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"gorm.io/gorm"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/shared/pkg/uuidv7"
)
type fileProcessingPermanentError struct {
err error
}
func (e *fileProcessingPermanentError) Error() string {
if e == nil || e.err == nil {
return "file processing permanent error"
}
return e.err.Error()
}
func (e *fileProcessingPermanentError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func wrapFileProcessingPermanent(err error) error {
if err == nil {
return nil
}
var marker *fileProcessingPermanentError
if errors.As(err, &marker) {
return err
}
return &fileProcessingPermanentError{err: err}
}
func IsFileProcessingPermanentError(err error) bool {
var marker *fileProcessingPermanentError
return errors.As(err, &marker)
}
type FileProcessingUseCase struct {
fileRepo filemanager.FileRepository
storage FileProcessingObjectStorage
logger *slog.Logger
fileStatusEvents filemanager.FileStatusRealtimeEventWriter
gotenbergURL string
}
type FileProcessingObjectStorage interface {
GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error)
PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error)
DeleteObject(ctx context.Context, objectKey string) error
}
type FileProcessingUseCaseDependencies struct {
Logger *slog.Logger
Storage FileProcessingObjectStorage
FileStatusEvents filemanager.FileStatusRealtimeEventWriter
GotenbergURL string
}
const (
jpegContentType = "image/jpeg"
pngContentType = "image/png"
jpegCompressionQuality = 80
thumbnailMaxDimension = 512
)
func stringPointerOrNil(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func NewFileProcessingUseCase(fileRepo filemanager.FileRepository, deps FileProcessingUseCaseDependencies) *FileProcessingUseCase {
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &FileProcessingUseCase{
fileRepo: fileRepo,
logger: logger,
storage: deps.Storage,
fileStatusEvents: deps.FileStatusEvents,
gotenbergURL: strings.TrimSpace(deps.GotenbergURL),
}
}
func (u *FileProcessingUseCase) SetFileStatusRealtimeEventWriter(writer filemanager.FileStatusRealtimeEventWriter) {
u.fileStatusEvents = writer
}
func (u *FileProcessingUseCase) SetObjectStorage(storage FileProcessingObjectStorage) {
u.storage = storage
}
func (u *FileProcessingUseCase) Process(ctx context.Context, job filemanager.FileProcessingJob, correlationID string) error {
if u.fileRepo == nil {
return wrapFileProcessingPermanent(errors.New("file repository is required"))
}
job = canonicalFileProcessingJob(job)
if err := validateFileProcessingJob(job); err != nil {
return wrapFileProcessingPermanent(err)
}
fileID, err := uuidv7.ParseString(job.FileUUID)
if err != nil {
return wrapFileProcessingPermanent(fmt.Errorf("parse file uuid: %w", err))
}
row, err := u.fileRepo.GetByIDAny(ctx, fileID)
if err != nil {
return err
}
if row == nil {
return wrapFileProcessingPermanent(filemanager.ErrFileNotFound)
}
status := filemanager.NormalizeFileStatus(row.Status)
switch status {
case filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
return nil
case filemanager.FileStatusUploaded:
changed, err := u.transitionToProcessing(ctx, fileID)
if err != nil {
return err
}
if changed {
row.Status = filemanager.FileStatusProcessing
row.UpdatedAt = time.Now().UTC()
u.emitStatusEvent(ctx, row, filemanager.FileStatusProcessing, "")
}
case filemanager.FileStatusProcessing:
// Continue from processing state for at-least-once delivery semantics.
default:
return wrapFileProcessingPermanent(fmt.Errorf("unexpected file status %q", status))
}
if err := validateFileProcessingPayload(row, job); err != nil {
reason := truncateFailureReason(err.Error())
changed, markErr := u.markFailedFrom(ctx, fileID, filemanager.FileStatusProcessing, reason)
if markErr != nil {
return markErr
}
if changed {
row.Status = filemanager.FileStatusFailed
row.FailureReason = reason
row.UpdatedAt = time.Now().UTC()
u.emitStatusEvent(ctx, row, filemanager.FileStatusFailed, reason)
}
u.logger.Warn("file processing failed",
slog.String("file_uuid", job.FileUUID),
slog.String("reason", reason),
slog.String("correlation_id", correlationID),
)
return nil
}
if err := u.compressObjectIfNeeded(ctx, row, correlationID); err != nil {
return err
}
if err := u.convertDocumentIfNeeded(ctx, row, correlationID); err != nil {
return err
}
changed, err := u.markValidated(ctx, fileID)
if err != nil {
return err
}
if changed {
row.Status = filemanager.FileStatusValidated
row.FailureReason = ""
row.UpdatedAt = time.Now().UTC()
u.emitStatusEvent(ctx, row, filemanager.FileStatusValidated, "")
}
readyChanged, err := u.markReady(ctx, fileID, row)
if err != nil {
reason := truncateFailureReason(err.Error())
failedChanged, markErr := u.markFailedFrom(ctx, fileID, filemanager.FileStatusValidated, reason)
if markErr != nil {
return markErr
}
if failedChanged {
row.Status = filemanager.FileStatusFailed
row.FailureReason = reason
row.UpdatedAt = time.Now().UTC()
u.emitStatusEvent(ctx, row, filemanager.FileStatusFailed, reason)
}
u.logger.Warn("file processing finalize failed",
slog.String("file_uuid", job.FileUUID),
slog.String("reason", reason),
slog.String("correlation_id", correlationID),
)
return nil
}
if readyChanged {
row.Status = filemanager.FileStatusReady
row.FailureReason = ""
row.UpdatedAt = time.Now().UTC()
u.emitStatusEvent(ctx, row, filemanager.FileStatusReady, "")
}
u.logger.Info("file processing completed",
slog.String("file_uuid", job.FileUUID),
slog.String("correlation_id", correlationID),
)
return nil
}
func (u *FileProcessingUseCase) compressObjectIfNeeded(ctx context.Context, row *filemanager.File, correlationID string) error {
if row == nil || u.storage == nil {
return nil
}
originalMimeType := normalizeUploadedMimeType(row.MimeType)
if !isCompressibleImageMimeType(originalMimeType) {
return nil
}
body, _, err := u.storage.GetObject(ctx, row.ObjectKey)
if err != nil {
return err
}
defer body.Close()
raw, err := io.ReadAll(body)
if err != nil {
return err
}
compressed, thumbnailContentType, err := compressImageForPreview(raw, originalMimeType)
if err != nil {
u.logger.Warn("file compression skipped: decode/encode failed",
slog.String("object_key", row.ObjectKey),
slog.String("mime_type", originalMimeType),
slog.String("error", err.Error()),
slog.String("correlation_id", correlationID),
)
return nil
}
if len(compressed) == 0 {
return nil
}
originalObjectKey := strings.TrimSpace(row.ObjectKey)
targetObjectKey := thumbnailObjectKey(originalObjectKey, thumbnailContentType)
meta, err := u.storage.PutObject(ctx, filemanager.PutObjectInput{
ObjectKey: targetObjectKey,
ContentType: thumbnailContentType,
SizeBytes: int64(len(compressed)),
Body: bytes.NewReader(compressed),
})
if err != nil {
return err
}
row.ThumbnailMimeType = stringPointerOrNil(thumbnailContentType)
row.ThumbnailSizeBytes = int64(len(compressed))
thumbnailBucket := strings.TrimSpace(meta.Bucket)
if thumbnailBucket == "" {
thumbnailBucket = strings.TrimSpace(row.Bucket)
}
row.ThumbnailBucket = stringPointerOrNil(thumbnailBucket)
thumbnailObjectKey := strings.TrimSpace(meta.ObjectKey)
if thumbnailObjectKey == "" {
thumbnailObjectKey = targetObjectKey
}
row.ThumbnailObjectKey = stringPointerOrNil(thumbnailObjectKey)
row.ThumbnailETag = stringPointerOrNil(meta.ETag)
row.ThumbnailObjectVersion = stringPointerOrNil(meta.VersionID)
if err := u.fileRepo.Update(ctx, row); err != nil {
return err
}
u.logger.Info("file thumbnail generated",
slog.String("object_key", row.ObjectKey),
slog.String("thumbnail_object_key", thumbnailObjectKey),
slog.String("mime_type_before", originalMimeType),
slog.String("mime_type_after", thumbnailContentType),
slog.Int("before_bytes", len(raw)),
slog.Int("after_bytes", len(compressed)),
slog.String("correlation_id", correlationID),
)
return nil
}
func isCompressibleImageMimeType(mimeType string) bool {
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
if !strings.HasPrefix(mimeType, "image/") {
return false
}
switch mimeType {
case "image/svg+xml", "image/webp":
return false
default:
return true
}
}
func compressImageForPreview(raw []byte, originalMimeType string) ([]byte, string, error) {
if len(raw) == 0 {
return nil, "", errors.New("empty image payload")
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", err
}
img = downscaleForThumbnail(img, thumbnailMaxDimension)
var out bytes.Buffer
switch normalizeUploadedMimeType(originalMimeType) {
case pngContentType, "image/gif":
encoder := png.Encoder{CompressionLevel: png.BestCompression}
if err := encoder.Encode(&out, img); err != nil {
return nil, "", err
}
return out.Bytes(), pngContentType, nil
default:
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: jpegCompressionQuality}); err != nil {
return nil, "", err
}
return out.Bytes(), jpegContentType, nil
}
}
func downscaleForThumbnail(src image.Image, maxDim int) image.Image {
if src == nil || maxDim <= 0 {
return src
}
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return src
}
if width <= maxDim && height <= maxDim {
return src
}
scale := float64(maxDim) / float64(width)
if height > width {
scale = float64(maxDim) / float64(height)
}
newWidth := int(float64(width) * scale)
newHeight := int(float64(height) * scale)
if newWidth < 1 {
newWidth = 1
}
if newHeight < 1 {
newHeight = 1
}
dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil)
return dst
}
func (u *FileProcessingUseCase) transitionToProcessing(ctx context.Context, fileID []byte) (bool, error) {
now := time.Now().UTC()
if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
ID: fileID,
ExpectedFrom: filemanager.FileStatusUploaded,
To: filemanager.FileStatusProcessing,
ProcessingStartedAt: &now,
}); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
if getErr != nil {
return false, getErr
}
if current == nil {
return false, wrapFileProcessingPermanent(filemanager.ErrFileNotFound)
}
switch filemanager.NormalizeFileStatus(current.Status) {
case filemanager.FileStatusProcessing, filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
return false, nil
default:
return false, err
}
}
return true, nil
}
func (u *FileProcessingUseCase) markValidated(ctx context.Context, fileID []byte) (bool, error) {
now := time.Now().UTC()
if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
ID: fileID,
ExpectedFrom: filemanager.FileStatusProcessing,
To: filemanager.FileStatusValidated,
ProcessedAt: &now,
ValidatedAt: &now,
}); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
if getErr != nil {
return false, getErr
}
if current == nil {
return false, nil
}
switch filemanager.NormalizeFileStatus(current.Status) {
case filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
return false, nil
default:
return false, err
}
}
return true, nil
}
func (u *FileProcessingUseCase) markFailedFrom(ctx context.Context, fileID []byte, expectedFrom string, reason string) (bool, error) {
now := time.Now().UTC()
if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
ID: fileID,
ExpectedFrom: expectedFrom,
To: filemanager.FileStatusFailed,
ProcessedAt: &now,
FailedAt: &now,
FailureReason: &reason,
}); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
if getErr != nil {
return false, getErr
}
if current == nil {
return false, nil
}
switch filemanager.NormalizeFileStatus(current.Status) {
case filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
return false, nil
default:
return false, err
}
}
return true, nil
}
func (u *FileProcessingUseCase) markReady(ctx context.Context, fileID []byte, row *filemanager.File) (bool, error) {
if row == nil {
return false, errors.New("file row is required")
}
nameNormalized := strings.TrimSpace(row.NameNormalized)
if nameNormalized == "" {
nameNormalized = strings.ToLower(strings.TrimSpace(row.Name))
}
updatedBy := row.UpdatedBy
if len(updatedBy) != 16 {
updatedBy = row.CreatedBy
}
if err := u.fileRepo.Finalize(ctx, filemanager.FileFinalizeParams{
ID: fileID,
ExpectedFrom: filemanager.FileStatusValidated,
FolderID: append([]byte(nil), row.FolderID...),
Name: row.Name,
NameNormalized: nameNormalized,
Extension: normalizeExtensionToken(firstNonEmptyString(row.Extension, extensionFromName(row.Name))),
UpdatedBy: updatedBy,
}); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return false, filemanager.ErrDuplicateName
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
if getErr != nil {
return false, getErr
}
if current == nil {
return false, nil
}
switch filemanager.NormalizeFileStatus(current.Status) {
case filemanager.FileStatusReady, filemanager.FileStatusFailed, filemanager.FileStatusTrashed:
return false, nil
default:
return false, err
}
}
return true, nil
}
func (u *FileProcessingUseCase) emitStatusEvent(ctx context.Context, row *filemanager.File, status, failureReason string) {
if u.fileStatusEvents == nil || row == nil {
return
}
userID := row.CreatedBy
if len(userID) != 16 {
userID = row.UpdatedBy
}
if len(userID) != 16 {
return
}
occurredAt := row.UpdatedAt
if occurredAt.IsZero() {
occurredAt = time.Now().UTC()
}
_ = u.fileStatusEvents.CreateFileStatusRealtimeEvent(ctx, filemanager.FileStatusRealtimeEventInput{
FileID: append([]byte(nil), row.ID...),
UserID: append([]byte(nil), userID...),
Status: status,
Name: row.Name,
FailureReason: strings.TrimSpace(failureReason),
OccurredAt: occurredAt,
})
}
func canonicalFileProcessingJob(job filemanager.FileProcessingJob) filemanager.FileProcessingJob {
job.JobID = strings.TrimSpace(job.JobID)
job.FileUUID = strings.TrimSpace(job.FileUUID)
job.Bucket = strings.TrimSpace(job.Bucket)
job.ObjectKey = strings.TrimSpace(job.ObjectKey)
job.UploadedBy = strings.TrimSpace(job.UploadedBy)
job.MimeType = strings.ToLower(strings.TrimSpace(job.MimeType))
job.TraceID = strings.TrimSpace(job.TraceID)
job.RequestID = strings.TrimSpace(job.RequestID)
return job
}
func validateFileProcessingJob(job filemanager.FileProcessingJob) error {
if strings.TrimSpace(job.FileUUID) == "" {
return errors.New("file uuid is required")
}
if strings.TrimSpace(job.Bucket) == "" {
return errors.New("bucket is required")
}
if strings.TrimSpace(job.ObjectKey) == "" {
return errors.New("object key is required")
}
if job.SizeBytes < 0 {
return errors.New("size bytes must be non-negative")
}
return nil
}
func validateFileProcessingPayload(row *filemanager.File, job filemanager.FileProcessingJob) error {
if row == nil {
return errors.New("file row is missing")
}
if strings.TrimSpace(row.Bucket) == "" {
return errors.New("file bucket is empty")
}
if strings.TrimSpace(row.ObjectKey) == "" {
return errors.New("file object key is empty")
}
if !strings.EqualFold(strings.TrimSpace(row.Bucket), strings.TrimSpace(job.Bucket)) {
return errors.New("file bucket mismatch")
}
rowObjectKey := strings.TrimSpace(row.ObjectKey)
jobObjectKey := strings.TrimSpace(job.ObjectKey)
if rowObjectKey != jobObjectKey {
// Keep compatibility with records converted by earlier worker behavior.
if rowObjectKey != webpObjectKey(jobObjectKey) {
return errors.New("file object key mismatch")
}
}
if row.SizeBytes < 0 {
return errors.New("file size is invalid")
}
return nil
}
func webpObjectKey(objectKey string) string {
objectKey = strings.TrimSpace(objectKey)
if objectKey == "" {
return objectKey
}
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(objectKey)))
if ext == ".webp" {
return objectKey
}
if ext == "" {
return objectKey + ".webp"
}
return strings.TrimSuffix(objectKey, ext) + ".webp"
}
func thumbnailObjectKey(objectKey, mimeType string) string {
objectKey = strings.TrimSpace(objectKey)
if objectKey == "" {
return objectKey
}
thumbExt := ".jpg"
switch normalizeUploadedMimeType(mimeType) {
case pngContentType:
thumbExt = ".png"
}
ext := strings.TrimSpace(filepath.Ext(objectKey))
if ext == "" {
return objectKey + ".thumb" + thumbExt
}
return strings.TrimSuffix(objectKey, ext) + ".thumb" + thumbExt
}
func previewObjectKey(objectKey string) string {
objectKey = strings.TrimSpace(objectKey)
if objectKey == "" {
return objectKey
}
ext := strings.TrimSpace(filepath.Ext(objectKey))
if ext == "" {
return objectKey + ".preview.pdf"
}
return strings.TrimSuffix(objectKey, ext) + ".preview.pdf"
}
func isConvertibleDocumentMimeType(mimeType string) bool {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // docx
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // xlsx
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // pptx
"application/msword", // doc
"application/vnd.ms-excel", // xls
"application/vnd.ms-powerpoint": // ppt
return true
default:
return false
}
}
func (u *FileProcessingUseCase) convertDocumentIfNeeded(ctx context.Context, row *filemanager.File, correlationID string) error {
if row == nil || u.storage == nil || u.gotenbergURL == "" {
return nil
}
if !isConvertibleDocumentMimeType(row.MimeType) {
return nil
}
body, _, err := u.storage.GetObject(ctx, row.ObjectKey)
if err != nil {
return fmt.Errorf("document preview: get original from storage: %w", err)
}
defer body.Close()
raw, err := io.ReadAll(body)
if err != nil {
return fmt.Errorf("document preview: read original: %w", err)
}
pdfBytes, err := u.callGotenberg(ctx, raw, row.Name)
if err != nil {
return err
}
targetKey := previewObjectKey(strings.TrimSpace(row.ObjectKey))
meta, err := u.storage.PutObject(ctx, filemanager.PutObjectInput{
ObjectKey: targetKey,
ContentType: "application/pdf",
SizeBytes: int64(len(pdfBytes)),
Body: bytes.NewReader(pdfBytes),
})
if err != nil {
return fmt.Errorf("document preview: upload preview to storage: %w", err)
}
previewMimeType := "application/pdf"
row.PreviewMimeType = &previewMimeType
row.PreviewSizeBytes = int64(len(pdfBytes))
previewBucket := strings.TrimSpace(meta.Bucket)
if previewBucket == "" {
previewBucket = strings.TrimSpace(row.Bucket)
}
row.PreviewBucket = &previewBucket
previewKey := strings.TrimSpace(meta.ObjectKey)
if previewKey == "" {
previewKey = targetKey
}
row.PreviewObjectKey = &previewKey
row.PreviewETag = stringPointerOrNil(meta.ETag)
row.PreviewObjectVersion = stringPointerOrNil(meta.VersionID)
if err := u.fileRepo.Update(ctx, row); err != nil {
return fmt.Errorf("document preview: save preview metadata: %w", err)
}
u.logger.Info("document preview generated",
slog.String("object_key", row.ObjectKey),
slog.String("preview_object_key", previewKey),
slog.String("mime_type", row.MimeType),
slog.Int("preview_bytes", len(pdfBytes)),
slog.String("correlation_id", correlationID),
)
return nil
}
func (u *FileProcessingUseCase) callGotenberg(ctx context.Context, raw []byte, filename string) ([]byte, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("files", filename)
if err != nil {
return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: create multipart field: %w", err))
}
if _, err := part.Write(raw); err != nil {
return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: write file to multipart: %w", err))
}
if err := writer.Close(); err != nil {
return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: close multipart writer: %w", err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.gotenbergURL+"/forms/libreoffice/convert", &body)
if err != nil {
return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: build gotenberg request: %w", err))
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Network or connection error — transient, allow retry.
return nil, fmt.Errorf("document preview: gotenberg request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("document preview: read gotenberg response: %w", err)
}
if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError {
// 4xx: bad document or unsupported format — permanent failure.
return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: gotenberg rejected document (status %d)", resp.StatusCode))
}
if resp.StatusCode != http.StatusOK {
// 5xx or unexpected — transient, allow retry.
return nil, fmt.Errorf("document preview: gotenberg returned status %d", resp.StatusCode)
}
if len(respBody) == 0 {
return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: gotenberg returned empty PDF"))
}
return respBody, nil
}

View File

@@ -0,0 +1,416 @@
package service
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/jpeg"
"io"
"strings"
"testing"
"gorm.io/gorm"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/shared/pkg/logger"
"wucher/internal/shared/pkg/uuidv7"
)
type fileProcessingStorageMock struct {
getObjectFn func(context.Context, string) (io.ReadCloser, filemanager.ObjectMetadata, error)
putObjectFn func(context.Context, filemanager.PutObjectInput) (filemanager.ObjectMetadata, error)
deleteFn func(context.Context, string) error
}
func (m *fileProcessingStorageMock) GetObject(ctx context.Context, key string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
if m.getObjectFn != nil {
return m.getObjectFn(ctx, key)
}
return io.NopCloser(bytes.NewReader(nil)), filemanager.ObjectMetadata{}, nil
}
func (m *fileProcessingStorageMock) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
if m.putObjectFn != nil {
return m.putObjectFn(ctx, input)
}
return filemanager.ObjectMetadata{}, nil
}
func (m *fileProcessingStorageMock) DeleteObject(ctx context.Context, key string) error {
if m.deleteFn != nil {
return m.deleteFn(ctx, key)
}
return nil
}
func TestFileProcessingUseCase_Process_Success(t *testing.T) {
fileID := uuidv7.MustBytes()
fileUUID, err := uuidv7.BytesToString(fileID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
row := &filemanager.File{
ID: append([]byte(nil), fileID...),
Status: filemanager.FileStatusUploaded,
Bucket: "bucket-a",
ObjectKey: "files/2026/04/03/a.pdf",
SizeBytes: 8,
CreatedBy: uuidv7.MustBytes(),
}
repo := &fileManagerFileRepoMock{
getByIDAnyResult: row,
transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
return gorm.ErrRecordNotFound
}
row.Status = params.To
row.ProcessingStartedAt = params.ProcessingStartedAt
row.ProcessedAt = params.ProcessedAt
row.ValidatedAt = params.ValidatedAt
row.FailedAt = params.FailedAt
if params.FailureReason != nil {
row.FailureReason = *params.FailureReason
}
return nil
},
}
useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
useCase.SetFileStatusRealtimeEventWriter(repo)
err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
FileUUID: fileUUID,
Bucket: row.Bucket,
ObjectKey: row.ObjectKey,
SizeBytes: row.SizeBytes,
}, "corr-1")
if err != nil {
t.Fatalf("process: %v", err)
}
if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady {
t.Fatalf("expected ready status, got %s", row.Status)
}
if repo.transitionStatusCalls != 2 {
t.Fatalf("expected 2 transitions (processing+validated), got %d", repo.transitionStatusCalls)
}
if !repo.finalizeCalled {
t.Fatalf("expected finalize to be called")
}
if len(repo.realtimeEvents) != 3 {
t.Fatalf("expected 3 realtime events, got %d", len(repo.realtimeEvents))
}
if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[0].Status); got != filemanager.FileStatusProcessing {
t.Fatalf("expected first realtime status processing, got %s", got)
}
if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[1].Status); got != filemanager.FileStatusValidated {
t.Fatalf("expected second realtime status validated, got %s", got)
}
if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[2].Status); got != filemanager.FileStatusReady {
t.Fatalf("expected third realtime status ready, got %s", got)
}
}
func TestFileProcessingUseCase_Process_MarkFailedOnValidationError(t *testing.T) {
fileID := uuidv7.MustBytes()
fileUUID, err := uuidv7.BytesToString(fileID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
row := &filemanager.File{
ID: append([]byte(nil), fileID...),
Status: filemanager.FileStatusUploaded,
Bucket: "bucket-a",
ObjectKey: "files/2026/04/03/a.pdf",
SizeBytes: 8,
CreatedBy: uuidv7.MustBytes(),
}
repo := &fileManagerFileRepoMock{
getByIDAnyResult: row,
transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
return gorm.ErrRecordNotFound
}
row.Status = params.To
row.ProcessingStartedAt = params.ProcessingStartedAt
row.ProcessedAt = params.ProcessedAt
row.ValidatedAt = params.ValidatedAt
row.FailedAt = params.FailedAt
if params.FailureReason != nil {
row.FailureReason = *params.FailureReason
}
return nil
},
}
useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
useCase.SetFileStatusRealtimeEventWriter(repo)
err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
FileUUID: fileUUID,
Bucket: "bucket-other",
ObjectKey: row.ObjectKey,
SizeBytes: row.SizeBytes,
}, "corr-2")
if err != nil {
t.Fatalf("process: %v", err)
}
if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusFailed {
t.Fatalf("expected failed status, got %s", row.Status)
}
if row.FailureReason == "" {
t.Fatalf("expected failure reason set")
}
if repo.transitionStatusCalls != 2 {
t.Fatalf("expected 2 transitions (processing+failed), got %d", repo.transitionStatusCalls)
}
if len(repo.realtimeEvents) != 2 {
t.Fatalf("expected 2 realtime events, got %d", len(repo.realtimeEvents))
}
if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[0].Status); got != filemanager.FileStatusProcessing {
t.Fatalf("expected first realtime status processing, got %s", got)
}
if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[1].Status); got != filemanager.FileStatusFailed {
t.Fatalf("expected second realtime status failed, got %s", got)
}
if strings.TrimSpace(repo.realtimeEvents[1].FailureReason) == "" {
t.Fatalf("expected failure reason on failed realtime event")
}
}
func TestFileProcessingUseCase_Process_MarkFailedOnFinalizeError(t *testing.T) {
fileID := uuidv7.MustBytes()
fileUUID, err := uuidv7.BytesToString(fileID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
row := &filemanager.File{
ID: append([]byte(nil), fileID...),
Status: filemanager.FileStatusUploaded,
Bucket: "bucket-a",
ObjectKey: "files/2026/04/03/a.pdf",
Name: "a.pdf",
NameNormalized: "a.pdf",
SizeBytes: 8,
CreatedBy: uuidv7.MustBytes(),
}
repo := &fileManagerFileRepoMock{
getByIDAnyResult: row,
transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
return gorm.ErrRecordNotFound
}
row.Status = params.To
row.ProcessingStartedAt = params.ProcessingStartedAt
row.ProcessedAt = params.ProcessedAt
row.ValidatedAt = params.ValidatedAt
row.FailedAt = params.FailedAt
if params.FailureReason != nil {
row.FailureReason = *params.FailureReason
}
return nil
},
finalizeErr: gorm.ErrDuplicatedKey,
}
useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
useCase.SetFileStatusRealtimeEventWriter(repo)
err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
FileUUID: fileUUID,
Bucket: row.Bucket,
ObjectKey: row.ObjectKey,
SizeBytes: row.SizeBytes,
}, "corr-5")
if err != nil {
t.Fatalf("process: %v", err)
}
if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusFailed {
t.Fatalf("expected failed status, got %s", row.Status)
}
if repo.transitionStatusCalls != 3 {
t.Fatalf("expected 3 transitions (processing+validated+failed), got %d", repo.transitionStatusCalls)
}
if len(repo.realtimeEvents) != 3 {
t.Fatalf("expected 3 realtime events, got %d", len(repo.realtimeEvents))
}
if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[2].Status); got != filemanager.FileStatusFailed {
t.Fatalf("expected third realtime status failed, got %s", got)
}
}
func TestFileProcessingUseCase_Process_IgnoreNonEligibleStatus(t *testing.T) {
fileID := uuidv7.MustBytes()
fileUUID, err := uuidv7.BytesToString(fileID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
row := &filemanager.File{
ID: append([]byte(nil), fileID...),
Status: filemanager.FileStatusReady,
Bucket: "bucket-a",
ObjectKey: "files/2026/04/03/a.pdf",
SizeBytes: 8,
}
repo := &fileManagerFileRepoMock{
getByIDAnyResult: row,
}
useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
FileUUID: fileUUID,
Bucket: row.Bucket,
ObjectKey: row.ObjectKey,
SizeBytes: row.SizeBytes,
}, "corr-3")
if err != nil {
t.Fatalf("process: %v", err)
}
if repo.transitionStatusCalls != 0 {
t.Fatalf("expected no transition for ready status")
}
}
func TestFileProcessingUseCase_Process_FileNotFoundPermanent(t *testing.T) {
fileUUID, err := uuidv7.String(uuidv7.MustNew())
if err != nil {
t.Fatalf("uuid string: %v", err)
}
repo := &fileManagerFileRepoMock{}
useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
FileUUID: fileUUID,
Bucket: "bucket-a",
ObjectKey: "files/2026/04/03/a.pdf",
SizeBytes: 8,
}, "corr-4")
if err == nil {
t.Fatalf("expected not found error")
}
if !IsFileProcessingPermanentError(err) {
t.Fatalf("expected permanent classification")
}
if !errors.Is(err, filemanager.ErrFileNotFound) {
t.Fatalf("expected ErrFileNotFound, got %v", err)
}
}
func TestFileProcessingUseCase_Process_CompressesJPEGInWorker(t *testing.T) {
fileID := uuidv7.MustBytes()
fileUUID, err := uuidv7.BytesToString(fileID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
// Create a JPEG and ensure worker converts image payload to WebP.
img := image.NewRGBA(image.Rect(0, 0, 1600, 900))
for y := 0; y < 900; y++ {
for x := 0; x < 1600; x++ {
img.Set(x, y, color.RGBA{R: uint8((x + y) % 255), G: uint8(x % 255), B: uint8(y % 255), A: 255})
}
}
var source bytes.Buffer
if err := jpeg.Encode(&source, img, &jpeg.Options{Quality: 100}); err != nil {
t.Fatalf("encode source jpeg: %v", err)
}
row := &filemanager.File{
ID: append([]byte(nil), fileID...),
Status: filemanager.FileStatusUploaded,
Bucket: "bucket-a",
ObjectKey: "files/2026/04/03/a.jpg",
SizeBytes: int64(source.Len()),
MimeType: "image/jpeg",
CreatedBy: uuidv7.MustBytes(),
}
repo := &fileManagerFileRepoMock{
getByIDAnyResult: row,
transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
return gorm.ErrRecordNotFound
}
row.Status = params.To
return nil
},
}
var putCalled bool
var uploadedBytes int
originalObjectKey := row.ObjectKey
thumbnailObjectKey := thumbnailObjectKey(originalObjectKey, jpegContentType)
storage := &fileProcessingStorageMock{
getObjectFn: func(_ context.Context, key string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
if key != originalObjectKey {
t.Fatalf("unexpected object key %q", key)
}
return io.NopCloser(bytes.NewReader(source.Bytes())), filemanager.ObjectMetadata{
Bucket: row.Bucket,
ObjectKey: originalObjectKey,
SizeBytes: int64(source.Len()),
}, nil
},
putObjectFn: func(_ context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
putCalled = true
if input.ObjectKey != thumbnailObjectKey {
t.Fatalf("unexpected put object key %q", input.ObjectKey)
}
if input.ContentType != jpegContentType {
t.Fatalf("expected content-type %q, got %q", jpegContentType, input.ContentType)
}
body, err := io.ReadAll(input.Body)
if err != nil {
return filemanager.ObjectMetadata{}, err
}
uploadedBytes = len(body)
return filemanager.ObjectMetadata{
Bucket: row.Bucket,
ObjectKey: thumbnailObjectKey,
ETag: "etag-new",
VersionID: "v2",
}, nil
},
}
useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
useCase.SetObjectStorage(storage)
err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
FileUUID: fileUUID,
Bucket: row.Bucket,
ObjectKey: row.ObjectKey,
SizeBytes: row.SizeBytes,
}, "corr-compress")
if err != nil {
t.Fatalf("process: %v", err)
}
if !putCalled {
t.Fatalf("expected compressed object upload")
}
if !repo.updateCalled {
t.Fatalf("expected file metadata update after compression")
}
if uploadedBytes <= 0 {
t.Fatalf("expected non-empty encoded thumbnail bytes")
}
if row.ThumbnailSizeBytes != int64(uploadedBytes) {
t.Fatalf("expected thumbnail size updated to %d, got %d", uploadedBytes, row.ThumbnailSizeBytes)
}
if row.MimeType != "image/jpeg" {
t.Fatalf("expected original mime type untouched, got %q", row.MimeType)
}
if row.ThumbnailMimeType == nil || *row.ThumbnailMimeType != jpegContentType {
t.Fatalf("expected thumbnail mime type %q, got %v", jpegContentType, row.ThumbnailMimeType)
}
if row.ObjectKey != originalObjectKey {
t.Fatalf("expected original object key untouched, got %q", row.ObjectKey)
}
if row.ThumbnailObjectKey == nil || *row.ThumbnailObjectKey != thumbnailObjectKey {
t.Fatalf("expected thumbnail object key %q, got %v", thumbnailObjectKey, row.ThumbnailObjectKey)
}
if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady {
t.Fatalf("expected ready status, got %s", row.Status)
}
}

View File

@@ -0,0 +1,42 @@
package service
import (
"context"
fleethistory "wucher/internal/domain/fleet_history"
)
type FleetHistoryService struct{ repo fleethistory.Repository }
func NewFleetHistoryService(repo fleethistory.Repository) *FleetHistoryService {
return &FleetHistoryService{repo: repo}
}
func (s *FleetHistoryService) Record(ctx context.Context, helicopterID []byte, entityType string, entityID []byte, action, message string, actor []byte) error {
if s == nil || s.repo == nil || len(helicopterID) != 16 {
return nil
}
return s.repo.Create(ctx, &fleethistory.FleetHistory{
HelicopterID: append([]byte(nil), helicopterID...),
EntityType: entityType,
EntityID: append([]byte(nil), entityID...),
Action: action,
Message: message,
Actor: append([]byte(nil), actor...),
})
}
func (s *FleetHistoryService) RecordByInspection(ctx context.Context, inspectionID []byte, entityType string, entityID []byte, action, message string, actor []byte) error {
if s == nil || s.repo == nil || len(inspectionID) != 16 {
return nil
}
helicopterID, err := s.repo.HelicopterIDByInspection(ctx, inspectionID)
if err != nil {
return err
}
return s.Record(ctx, helicopterID, entityType, entityID, action, message, actor)
}
func (s *FleetHistoryService) ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]fleethistory.FleetHistory, int64, error) {
return s.repo.ListByHelicopter(ctx, helicopterID, limit, offset)
}

View File

@@ -0,0 +1,11 @@
package service
import "wucher/internal/domain/file_manager"
type FleetStatusFileService struct {
fileManager filemanager.Service
}
func NewFleetStatusFileService(fileManager filemanager.Service) *FleetStatusFileService {
return &FleetStatusFileService{fileManager: fileManager}
}

View File

@@ -0,0 +1,664 @@
package service
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
complaint "wucher/internal/domain/complaint"
filemanager "wucher/internal/domain/file_manager"
fleethistory "wucher/internal/domain/fleet_history"
fleetstatus "wucher/internal/domain/fleet_status"
"wucher/internal/domain/helicopter"
helicopterusage "wucher/internal/domain/helicopter_usage"
"wucher/internal/shared/pkg/nextdue"
"wucher/internal/shared/pkg/uuidv7"
shareddto "wucher/internal/transport/http/dto/shared"
)
type fleetStatusAttachments interface {
CreateAttachment(ctx context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error)
DeleteAttachment(ctx context.Context, id []byte) error
}
type fleetStatusHelicopters interface {
GetByID(ctx context.Context, id []byte) (*helicopter.Helicopter, error)
Update(ctx context.Context, h *helicopter.Helicopter) error
}
type fleetStatusComplaints interface {
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error)
ResolveUserNames(ctx context.Context, complaints []complaint.Complaint) (map[string]string, error)
}
type fleetStatusUsage interface {
GetByHelicopterID(ctx context.Context, helicopterID []byte) (*helicopterusage.HelicopterUsage, error)
ReviseManualTotals(ctx context.Context, helicopterID []byte, in helicopterusage.ManualSummaryInput, actor []byte) error
}
const (
FleetStatusFileRefType = "fleet_status"
autoAOGReasonPrefixNextDue = "[auto_next_due]"
autoAOGReasonPrefixComplaint = "[auto_complaint]"
)
type NextDueEvaluation struct {
InspectionType string
AOG bool
Reason string
}
type ComponentHours struct {
Airframe float64
Engine1 float64
Engine2 float64
}
type FleetStatusService struct {
repo fleetstatus.Repository
attachments fleetStatusAttachments
helicopters fleetStatusHelicopters
complaints fleetStatusComplaints
history *FleetHistoryService
usage fleetStatusUsage
}
func (s *FleetStatusService) Create(ctx context.Context, row *fleetstatus.FleetStatus) error {
if s.attachments == nil {
if err := s.repo.Create(ctx, row); err != nil {
return err
}
return s.syncAutomaticAOG(ctx, row)
}
if len(row.ID) == 0 {
row.ID = uuidv7.MustBytes()
}
files, createdIDs, err := s.resolveFileLinks(ctx, row.ID, row.Files, nil, row.UpdatedBy)
if err != nil {
return err
}
row.Files = files
if err := s.repo.Create(ctx, row); err != nil {
s.rollbackAttachments(ctx, createdIDs)
return err
}
if err := s.syncAutomaticAOG(ctx, row); err != nil {
return err
}
return nil
}
func (s *FleetStatusService) Update(ctx context.Context, row *fleetstatus.FleetStatus) error {
if s.attachments == nil {
if err := s.repo.Update(ctx, row); err != nil {
return err
}
return s.syncAutomaticAOG(ctx, row)
}
newKeys := make(map[string]struct{})
for i := range row.Files {
if len(row.Files[i].FileID) == 16 {
key, _ := uuidv7.BytesToString(row.Files[i].FileID)
newKeys[key] = struct{}{}
}
}
if len(newKeys) == 0 {
if err := s.repo.Update(ctx, row); err != nil {
return err
}
return s.syncAutomaticAOG(ctx, row)
}
existing, err := s.repo.GetByID(ctx, row.ID)
if err != nil {
return err
}
reuse := make(map[string][]byte)
type oldRef struct {
key string
attID []byte
}
var olds []oldRef
if existing != nil {
for i := range existing.Files {
ef := existing.Files[i]
if ef.Attachment == nil || len(ef.Attachment.FileID) != 16 {
continue
}
key, _ := uuidv7.BytesToString(ef.Attachment.FileID)
reuse[key] = ef.FileAttachmentID
olds = append(olds, oldRef{key: key, attID: ef.FileAttachmentID})
}
}
files, createdIDs, err := s.resolveFileLinks(ctx, row.ID, row.Files, reuse, row.UpdatedBy)
if err != nil {
return err
}
row.Files = files
if err := s.repo.Update(ctx, row); err != nil {
s.rollbackAttachments(ctx, createdIDs)
return err
}
if err := s.syncAutomaticAOG(ctx, row); err != nil {
return err
}
for _, o := range olds {
if _, keep := newKeys[o.key]; keep {
continue
}
_ = s.attachments.DeleteAttachment(ctx, o.attID)
}
return nil
}
func (s *FleetStatusService) WithUsage(u fleetStatusUsage) *FleetStatusService {
s.usage = u
return s
}
func (s *FleetStatusService) ReviseSummary(ctx context.Context, helicopterID []byte, in helicopterusage.ManualSummaryInput, actor []byte) error {
if s.usage == nil || len(helicopterID) != 16 {
return nil
}
return s.usage.ReviseManualTotals(ctx, helicopterID, in, actor)
}
func (s *FleetStatusService) WithHistory(h *FleetHistoryService) *FleetStatusService {
s.history = h
return s
}
func (s *FleetStatusService) recordAutoAOG(ctx context.Context, heli *helicopter.Helicopter, set bool, reason string) {
if s.history == nil || heli == nil {
return
}
action := fleethistory.ActionAOGCleared
msg := "AOG cleared (auto: maintenance no longer due)"
if set {
action = fleethistory.ActionAOGSet
msg = "AOG set (auto: maintenance next due)"
if r := strings.TrimSpace(reason); r != "" {
msg = "AOG set (auto) — " + r
}
}
if err := s.history.Record(ctx, heli.ID, fleethistory.EntityHelicopter, heli.ID, action, msg, nil); err != nil {
slog.WarnContext(ctx, "fleet status: record auto-AOG history failed", slog.Any("error", err))
}
}
func NewFleetStatusService(repo fleetstatus.Repository, attachments fleetStatusAttachments, helicopters fleetStatusHelicopters, complaints fleetStatusComplaints) *FleetStatusService {
return &FleetStatusService{repo: repo, attachments: attachments, helicopters: helicopters, complaints: complaints}
}
func (s *FleetStatusService) syncAutomaticAOG(ctx context.Context, row *fleetstatus.FleetStatus) error {
if s == nil || s.helicopters == nil || row == nil || len(row.HelicopterID) != 16 {
return nil
}
heli, err := s.helicopters.GetByID(ctx, row.HelicopterID)
if err != nil {
return err
}
if heli == nil {
idStr, _ := uuidv7.BytesToString(row.HelicopterID)
slog.WarnContext(ctx, "fleet status: helicopter not found, skipping AOG sync", slog.String("helicopter_id", idStr))
return nil
}
now := time.Now().UTC()
var evals []NextDueEvaluation
if hasEligibleSchedule(row.MaintenanceSchedules) {
summary, sErr := s.GetSummaryByHelicopterID(ctx, row.HelicopterID)
if sErr != nil {
return sErr
}
hours := ComponentHours{
Airframe: derefFloat(summary.Airframe.Hours),
Engine1: derefFloat(summary.Engine1.Hours),
Engine2: derefFloat(summary.Engine2.Hours),
}
evals, err = EvaluateNextDueSchedules(row.MaintenanceSchedules, hours, summary.Airframe.Hours != nil, heli.NR1, heli.NR2, now)
if err != nil {
return err
}
}
loadComplaints := func() ([]complaint.Complaint, error) {
if s.complaints == nil {
return nil, nil
}
rows, _, lErr := s.complaints.ListByHelicopter(ctx, row.HelicopterID, 100, 0)
return rows, lErr
}
grounded, reason, source, err := decideAutoAOG(evals, now, loadComplaints, func(c complaint.Complaint) string {
return s.complaintAOGReason(ctx, c)
})
if err != nil {
return err
}
return s.applyAutoAOG(ctx, heli, grounded, reason, source)
}
func decideAutoAOG(evals []NextDueEvaluation, now time.Time, loadComplaints func() ([]complaint.Complaint, error), complaintReason func(complaint.Complaint) string) (grounded bool, reason string, source string, err error) {
for i := range evals {
if !evals[i].AOG {
continue
}
cleanReason := strings.TrimSpace(evals[i].Reason)
if cleanReason == "" {
cleanReason = fmt.Sprintf("%s next due reached", evals[i].InspectionType)
}
return true, autoAOGReasonPrefixNextDue + " " + cleanReason, helicopter.AOGSourceAuto, nil
}
complaints, err := loadComplaints()
if err != nil {
return false, "", "", err
}
for i := range complaints {
if complaints[i].IsGrounding(now) {
return true, complaintReason(complaints[i]), helicopter.AOGSourceComplaint, nil
}
}
return false, "", "", nil
}
func (s *FleetStatusService) applyAutoAOG(ctx context.Context, heli *helicopter.Helicopter, grounded bool, reason, source string) error {
if grounded {
wasAOG := heli.AirOnGround
if !heli.AirOnGround || strings.TrimSpace(heli.AOGReason) != reason || strings.TrimSpace(heli.AOGSource) != source {
heli.AirOnGround = true
heli.AOGReason = reason
heli.AOGSource = source
if err := s.helicopters.Update(ctx, heli); err != nil {
return err
}
if !wasAOG {
s.recordAutoAOG(ctx, heli, true, reason)
}
}
return nil
}
if isAutoAOG(heli) {
heli.AirOnGround = false
heli.AOGReason = ""
heli.AOGSource = helicopter.AOGSourceUnknown
if err := s.helicopters.Update(ctx, heli); err != nil {
return err
}
s.recordAutoAOG(ctx, heli, false, "")
}
return nil
}
func hasEligibleSchedule(schedules []fleetstatus.MaintenanceSchedule) bool {
for i := range schedules {
if strings.TrimSpace(schedules[i].Due) != "" || strings.TrimSpace(schedules[i].Ext) != "" {
return true
}
}
return false
}
func (s *FleetStatusService) OverdueInspections(ctx context.Context, helicopterID []byte) ([]fleetstatus.OverdueInspection, error) {
if s == nil || s.helicopters == nil || len(helicopterID) != 16 {
return nil, nil
}
latest, err := s.repo.LatestByHelicopterIDs(ctx, [][]byte{helicopterID})
if err != nil {
return nil, err
}
row := latest[string(helicopterID)]
if row == nil || !hasEligibleSchedule(row.MaintenanceSchedules) {
return nil, nil
}
heli, err := s.helicopters.GetByID(ctx, helicopterID)
if err != nil || heli == nil {
return nil, err
}
summary, err := s.GetSummaryByHelicopterID(ctx, helicopterID)
if err != nil {
return nil, err
}
hours := ComponentHours{
Airframe: derefFloat(summary.Airframe.Hours),
Engine1: derefFloat(summary.Engine1.Hours),
Engine2: derefFloat(summary.Engine2.Hours),
}
evals, err := EvaluateNextDueSchedules(row.MaintenanceSchedules, hours, summary.Airframe.Hours != nil, heli.NR1, heli.NR2, time.Now().UTC())
if err != nil {
return nil, err
}
out := make([]fleetstatus.OverdueInspection, 0, len(evals))
for i := range evals {
if !evals[i].AOG {
continue
}
detail := strings.TrimSpace(evals[i].Reason)
if detail == "" {
detail = evals[i].InspectionType + " next due reached"
}
out = append(out, fleetstatus.OverdueInspection{InspectionType: evals[i].InspectionType, Detail: detail})
}
return out, nil
}
func isAutoAOG(heli *helicopter.Helicopter) bool {
if heli == nil || !heli.AirOnGround {
return false
}
src := strings.TrimSpace(heli.AOGSource)
if src == helicopter.AOGSourceAuto || src == helicopter.AOGSourceComplaint {
return true
}
reason := strings.TrimSpace(heli.AOGReason)
return strings.HasPrefix(reason, autoAOGReasonPrefixNextDue) || strings.HasPrefix(reason, autoAOGReasonPrefixComplaint)
}
func (s *FleetStatusService) complaintAOGReason(ctx context.Context, c complaint.Complaint) string {
if c.MELSeverity > complaint.MELSeverityNonMEL {
return autoAOGReasonPrefixComplaint + " MEL " + shareddto.MELLabel(c.MELSeverity) + " grace period expired"
}
if s.complaints != nil {
if names, err := s.complaints.ResolveUserNames(ctx, []complaint.Complaint{c}); err == nil {
if name := names[string(c.ReportedBy)]; name != "" {
return autoAOGReasonPrefixComplaint + " NON-MEL complaint reported by " + name
}
}
}
return autoAOGReasonPrefixComplaint + " NON-MEL complaint reported"
}
func derefFloat(v *float64) float64 {
if v == nil {
return 0
}
return *v
}
func (s *FleetStatusService) resolveFileLinks(
ctx context.Context,
fleetStatusID []byte,
files []fleetstatus.FleetStatusFile,
reuse map[string][]byte,
actor []byte,
) ([]fleetstatus.FleetStatusFile, [][]byte, error) {
refID, _ := uuidv7.BytesToString(fleetStatusID)
out := make([]fleetstatus.FleetStatusFile, 0, len(files))
createdIDs := make([][]byte, 0, len(files))
for i := range files {
f := files[i]
f.FleetStatusID = fleetStatusID
if len(f.FileID) != 16 {
if len(f.FileAttachmentID) == 16 {
out = append(out, f)
}
continue
}
key, _ := uuidv7.BytesToString(f.FileID)
if attID, ok := reuse[key]; ok {
f.FileAttachmentID = attID
f.FileID = nil
out = append(out, f)
continue
}
cat := f.Category
att, err := s.attachments.CreateAttachment(ctx, filemanager.CreateAttachmentParams{
FileID: f.FileID,
RefType: FleetStatusFileRefType,
RefID: refID,
Category: &cat,
ActorID: actor,
})
if err != nil {
s.rollbackAttachments(ctx, createdIDs)
return nil, nil, err
}
createdIDs = append(createdIDs, att.ID)
f.FileAttachmentID = att.ID
f.FileID = nil
out = append(out, f)
}
return out, createdIDs, nil
}
func (s *FleetStatusService) rollbackAttachments(ctx context.Context, ids [][]byte) {
for _, id := range ids {
_ = s.attachments.DeleteAttachment(ctx, id)
}
}
func (s *FleetStatusService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *FleetStatusService) GetByID(ctx context.Context, id []byte) (*fleetstatus.FleetStatus, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FleetStatusService) List(ctx context.Context, filter, sort string, limit, offset int) ([]fleetstatus.FleetStatus, int64, error) {
return s.repo.List(ctx, filter, normalizeSortByRules(sort,
sortField("created_at"),
sortField("updated_at"),
), limit, offset)
}
func (s *FleetStatusService) LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*fleetstatus.FleetStatus, error) {
return s.repo.LatestByHelicopterIDs(ctx, helicopterIDs)
}
func (s *FleetStatusService) RecalculateAOGByHelicopter(ctx context.Context, helicopterID []byte) error {
if s == nil || len(helicopterID) != 16 {
return nil
}
latest, err := s.repo.LatestByHelicopterIDs(ctx, [][]byte{helicopterID})
if err != nil {
return err
}
row := latest[string(helicopterID)]
if row == nil {
row = &fleetstatus.FleetStatus{HelicopterID: append([]byte(nil), helicopterID...)}
}
return s.syncAutomaticAOG(ctx, row)
}
func (s *FleetStatusService) RecalculateAllAOG(ctx context.Context) (int, error) {
if s == nil {
return 0, nil
}
ids, err := s.repo.HelicopterIDsWithFleetStatus(ctx)
if err != nil {
return 0, err
}
n := 0
failed := 0
for _, id := range ids {
if err := s.RecalculateAOGByHelicopter(ctx, id); err != nil {
failed++
idStr, _ := uuidv7.BytesToString(id)
slog.WarnContext(ctx, "fleet status AOG recompute failed for helicopter", slog.String("helicopter_id", idStr), slog.Any("error", err))
continue
}
n++
}
if failed > 0 {
slog.WarnContext(ctx, "fleet status AOG recompute completed with failures", slog.Int("succeeded", n), slog.Int("failed", failed))
}
return n, nil
}
func (s *FleetStatusService) RecalculateAOGByMission(ctx context.Context, missionID []byte) error {
if s == nil || len(missionID) == 0 {
return nil
}
helicopterID, err := s.repo.HelicopterIDByMissionID(ctx, missionID)
if err != nil {
return err
}
return s.RecalculateAOGByHelicopter(ctx, helicopterID)
}
func (s *FleetStatusService) MarkServiced(ctx context.Context, id []byte, servicedAt time.Time, actorID []byte) error {
if servicedAt.IsZero() {
servicedAt = time.Now().UTC()
}
if err := s.repo.MarkServiced(ctx, id, servicedAt.UTC(), actorID); err != nil {
return err
}
row, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if row == nil {
idStr, _ := uuidv7.BytesToString(id)
slog.WarnContext(ctx, "fleet status: not found after mark-serviced, skipping AOG sync", slog.String("id", idStr))
return nil
}
return s.syncAutomaticAOG(ctx, row)
}
func (s *FleetStatusService) ListServiceHistoryByHelicopterID(ctx context.Context, helicopterID []byte, limit, offset int) ([]fleetstatus.FleetStatusServiceLog, int64, error) {
return s.repo.ListServiceHistoryByHelicopterID(ctx, helicopterID, limit, offset)
}
func (s *FleetStatusService) GetSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.FleetStatusSummary, error) {
if s.usage == nil || len(helicopterID) != 16 {
return &shareddto.FleetStatusSummary{}, nil
}
usage, err := s.usage.GetByHelicopterID(ctx, helicopterID)
if err != nil {
return &shareddto.FleetStatusSummary{}, err
}
if usage == nil {
return &shareddto.FleetStatusSummary{}, nil
}
hasE1, hasE2 := true, true
if s.helicopters != nil {
if heli, herr := s.helicopters.GetByID(ctx, helicopterID); herr == nil && heli != nil {
hasE1, hasE2 = heli.NR1, heli.NR2
}
}
summary := buildFleetStatusSummary(usage, hasE1, hasE2)
return &summary, nil
}
func EvaluateNextDueSchedules(schedules []fleetstatus.MaintenanceSchedule, hours ComponentHours, hasUsageData, hasEngine1, hasEngine2 bool, now time.Time) ([]NextDueEvaluation, error) {
parser := nextdue.NewParser()
out := make([]NextDueEvaluation, 0, len(schedules))
for i := range schedules {
it := strings.TrimSpace(schedules[i].InspectionType)
if (it == fleetstatus.InspectionTypeENG1 && !hasEngine1) || (it == fleetstatus.InspectionTypeENG2 && !hasEngine2) {
out = append(out, NextDueEvaluation{InspectionType: it})
continue
}
current := hours.Airframe
switch it {
case fleetstatus.InspectionTypeENG1:
current = hours.Engine1
case fleetstatus.InspectionTypeENG2:
current = hours.Engine2
}
ev, err := evaluateInspectionDue(parser, it, schedules[i].Due, schedules[i].Ext, current, hasUsageData, now)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, nil
}
func evaluateInspectionDue(parser nextdue.Parser, inspectionType, dueRaw, extRaw string, currentHours float64, hasUsageData bool, now time.Time) (NextDueEvaluation, error) {
due, err := parser.Parse("due", dueRaw)
if err != nil {
return NextDueEvaluation{}, err
}
ext, err := parser.Parse("ext", extRaw)
if err != nil {
return NextDueEvaluation{}, err
}
ev := NextDueEvaluation{InspectionType: strings.TrimSpace(inspectionType)}
limit := due
limitIsExt := false
if !nextdue.IsUnset(ext) {
limit, limitIsExt = ext, true
}
if nextdue.IsUnset(limit) {
return ev, nil
}
switch limit.Kind {
case nextdue.ValueKindHours:
if !hasUsageData {
return ev, nil
}
if limit.Hours != nil && currentHours > *limit.Hours {
ev.AOG = true
ev.Reason = nextDueOverdueReason(inspectionType, limitIsExt, true)
}
case nextdue.ValueKindDate:
if limit.Date != nil && nextdue.NormalizeDate(now).After(nextdue.NormalizeDate(*limit.Date)) {
ev.AOG = true
ev.Reason = nextDueOverdueReason(inspectionType, limitIsExt, false)
}
}
return ev, nil
}
func nextDueOverdueReason(inspectionType string, isExt, isHours bool) string {
it := strings.TrimSpace(inspectionType)
switch {
case isExt && isHours:
return it + " extended hours reached"
case isExt:
return it + " extended due date reached"
case isHours:
return it + " due hours reached"
default:
return it + " due date reached"
}
}
func buildFleetStatusSummary(u *helicopterusage.HelicopterUsage, hasE1, hasE2 bool) shareddto.FleetStatusSummary {
airframeHours := u.TotalAirframeHours
airframeCycles := float64(u.TotalAirframeCycles)
landings := float64(u.TotalLanding)
flightReports := float64(u.TotalFlightReport)
rotorBrakeCycles := float64(u.TotalRotorBrakeCycle)
hookReleases := float64(u.TotalHookRelease)
summary := shareddto.FleetStatusSummary{
Airframe: shareddto.FleetStatusSummaryAirframe{
Hours: &airframeHours,
Cycles: &airframeCycles,
},
Landings: &landings,
FlightReports: &flightReports,
RotorBrakeCycles: &rotorBrakeCycles,
HookReleases: &hookReleases,
}
if hasE1 {
summary.Engine1 = buildFleetStatusUsageEngine(u.TotalEngine1Hours, u.TotalEngine1GpcNgN1, u.TotalEngine1PtcNfN2, u.TotalEngine1Ccc)
}
if hasE2 {
summary.Engine2 = buildFleetStatusUsageEngine(u.TotalEngine2Hours, u.TotalEngine2GpcNgN1, u.TotalEngine2PtcNfN2, u.TotalEngine2Ccc)
}
return summary
}
func buildFleetStatusUsageEngine(hours, gpcNgN1, ptcNfN2, ccc float64) shareddto.FleetStatusSummaryEngine {
cccVal := ccc
return shareddto.FleetStatusSummaryEngine{
Hours: &hours,
GpcNgN1: &gpcNgN1,
PtcNfN2: &ptcNfN2,
Ccc: &cccVal,
}
}

View File

@@ -0,0 +1,267 @@
package service
import (
"errors"
"strings"
"testing"
"time"
complaint "wucher/internal/domain/complaint"
fleetstatus "wucher/internal/domain/fleet_status"
"wucher/internal/domain/helicopter"
helicopterusage "wucher/internal/domain/helicopter_usage"
"wucher/internal/shared/pkg/nextdue"
)
func ptr[T any](v T) *T { return &v }
func TestDecideAutoAOG(t *testing.T) {
now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
reasonFn := func(c complaint.Complaint) string { return "complaint:" + c.Description }
noComplaints := func() ([]complaint.Complaint, error) { return nil, nil }
t.Run("next due grounds; complaints not consulted", func(t *testing.T) {
loaderCalled := false
loader := func() ([]complaint.Complaint, error) { loaderCalled = true; return nil, nil }
evals := []NextDueEvaluation{{InspectionType: "A/F", AOG: true, Reason: "A/F due hours reached"}}
grounded, reason, source, err := decideAutoAOG(evals, now, loader, reasonFn)
if err != nil || !grounded {
t.Fatalf("expected grounded, got grounded=%v err=%v", grounded, err)
}
if source != helicopter.AOGSourceAuto {
t.Fatalf("source = %q, want auto", source)
}
if !strings.HasPrefix(reason, autoAOGReasonPrefixNextDue) {
t.Fatalf("reason %q missing next-due prefix", reason)
}
if loaderCalled {
t.Fatal("next-due grounded -> complaints must not be loaded (short-circuit)")
}
})
t.Run("complaint grounds when no next due", func(t *testing.T) {
classified := now.Add(-time.Hour)
loader := func() ([]complaint.Complaint, error) {
return []complaint.Complaint{{Description: "blade crack", MELSeverity: complaint.MELSeverityNonMEL, ReportedAt: now.Add(-time.Hour), MELClassifiedAt: &classified}}, nil
}
grounded, reason, source, err := decideAutoAOG(nil, now, loader, reasonFn)
if err != nil || !grounded {
t.Fatalf("expected grounded, got grounded=%v err=%v", grounded, err)
}
if source != helicopter.AOGSourceComplaint {
t.Fatalf("source = %q, want complaint", source)
}
if reason != "complaint:blade crack" {
t.Fatalf("reason = %q, want injected complaint reason", reason)
}
})
t.Run("nothing grounds", func(t *testing.T) {
grounded, _, _, err := decideAutoAOG(nil, now, noComplaints, reasonFn)
if err != nil || grounded {
t.Fatalf("expected not grounded, got grounded=%v err=%v", grounded, err)
}
})
t.Run("loader error propagates", func(t *testing.T) {
loader := func() ([]complaint.Complaint, error) { return nil, errors.New("db down") }
if _, _, _, err := decideAutoAOG(nil, now, loader, reasonFn); err == nil {
t.Fatal("expected loader error to propagate")
}
})
}
func assertFloatPtr(t *testing.T, label string, got, want *float64) {
t.Helper()
if want == nil {
if got != nil {
t.Errorf("%s = %v, want nil", label, *got)
}
return
}
if got == nil {
t.Errorf("%s = nil, want %v", label, *want)
return
}
const eps = 1e-9
if diff := *got - *want; diff < -eps || diff > eps {
t.Errorf("%s = %v, want %v", label, *got, *want)
}
}
func TestBuildFleetStatusSummaryEngineGating(t *testing.T) {
u := &helicopterusage.HelicopterUsage{TotalAirframeHours: 10}
got := buildFleetStatusSummary(u, false, false)
assertFloatPtr(t, "airframe.hours", got.Airframe.Hours, ptr(10.0))
if got.Engine1.Hours != nil || got.Engine2.Hours != nil {
t.Errorf("expected empty engines, got %+v / %+v", got.Engine1, got.Engine2)
}
}
func TestBuildFleetStatusSummaryFromUsage(t *testing.T) {
u := &helicopterusage.HelicopterUsage{
TotalLanding: 10,
TotalAirframeHours: 3,
TotalAirframeCycles: 20,
TotalEngine1Hours: 8011,
TotalEngine1GpcNgN1: 101,
TotalEngine1PtcNfN2: 102,
TotalEngine1Ccc: 7,
TotalEngine2Hours: 5981,
TotalRotorBrakeCycle: 4,
TotalHookRelease: 2,
}
got := buildFleetStatusSummary(u, true, false)
assertFloatPtr(t, "airframe.hours", got.Airframe.Hours, ptr(3.0))
assertFloatPtr(t, "airframe.cycles", got.Airframe.Cycles, ptr(20.0))
assertFloatPtr(t, "engine1.hours", got.Engine1.Hours, ptr(8011.0))
assertFloatPtr(t, "engine1.gpc_ng_n1", got.Engine1.GpcNgN1, ptr(101.0))
assertFloatPtr(t, "engine1.ptc_nf_n2", got.Engine1.PtcNfN2, ptr(102.0))
assertFloatPtr(t, "engine1.ccc", got.Engine1.Ccc, ptr(7.0))
if got.Engine2.Hours != nil {
t.Errorf("engine 2 absent (NR2 false) -> hours must be nil, got %v", *got.Engine2.Hours)
}
assertFloatPtr(t, "landings", got.Landings, ptr(10.0))
assertFloatPtr(t, "rotor_brake", got.RotorBrakeCycles, ptr(4.0))
assertFloatPtr(t, "hook_releases", got.HookReleases, ptr(2.0))
}
func TestDerefFloat(t *testing.T) {
if derefFloat(nil) != 0 {
t.Fatal("nil should deref to 0")
}
if derefFloat(ptr(12.5)) != 12.5 {
t.Fatal("deref value mismatch")
}
}
func TestEvaluateNextDueSchedulesPerComponent(t *testing.T) {
now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
schedules := []fleetstatus.MaintenanceSchedule{
{InspectionType: fleetstatus.InspectionTypeAF, Due: "100"},
{InspectionType: fleetstatus.InspectionTypeENG1, Due: "100", Ext: "120"},
{InspectionType: fleetstatus.InspectionTypeENG2, Due: "100"},
}
hours := ComponentHours{Airframe: 150, Engine1: 110, Engine2: 90}
// Engine 2 present: ENG2 (90 < 100) not overdue; A/F (150>100) overdue; ENG1 (110<120 ext) not overdue.
evals, err := EvaluateNextDueSchedules(schedules, hours, true, true, true, now)
if err != nil {
t.Fatalf("err: %v", err)
}
got := map[string]bool{}
for _, e := range evals {
got[e.InspectionType] = e.AOG
}
if !got[fleetstatus.InspectionTypeAF] {
t.Error("A/F should be overdue (150 > 100)")
}
if got[fleetstatus.InspectionTypeENG1] {
t.Error("ENG1 should NOT be overdue (110 < 120 ext)")
}
if got[fleetstatus.InspectionTypeENG2] {
t.Error("ENG2 should NOT be overdue (90 < 100)")
}
// Engine 2 absent: an ENG2 schedule must be skipped even if hours exceed the limit.
hours.Engine2 = 999
evals2, err := EvaluateNextDueSchedules(schedules, hours, true, true, false, now)
if err != nil {
t.Fatalf("err: %v", err)
}
for _, e := range evals2 {
if e.InspectionType == fleetstatus.InspectionTypeENG2 && e.AOG {
t.Error("absent engine 2 must not ground the aircraft")
}
}
}
func TestEvaluateInspectionDueHours(t *testing.T) {
now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
p := nextdue.NewParser()
cases := []struct {
name string
due string
ext string
current float64
wantAOG bool
}{
{name: "below due", due: "100", ext: "", current: 99, wantAOG: false},
{name: "exactly at due not overdue", due: "100", ext: "", current: 100, wantAOG: false},
{name: "past due", due: "100", ext: "", current: 101, wantAOG: true},
{name: "ext extends beyond due", due: "100", ext: "120", current: 110, wantAOG: false},
{name: "past ext", due: "100", ext: "120", current: 121, wantAOG: true},
{name: "ext zero treated as unset", due: "100", ext: "0", current: 101, wantAOG: true},
{name: "unset", due: "", ext: "", current: 9999, wantAOG: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ev, err := evaluateInspectionDue(p, "A/F", tc.due, tc.ext, tc.current, true, now)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ev.AOG != tc.wantAOG {
t.Fatalf("AOG = %v (reason %q), want %v", ev.AOG, ev.Reason, tc.wantAOG)
}
})
}
}
func TestEvaluateInspectionDueDate(t *testing.T) {
now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
p := nextdue.NewParser()
cases := []struct {
name string
due string
ext string
wantAOG bool
}{
{name: "due date passed", due: "2026-06-10", ext: "", wantAOG: true},
{name: "due date today not yet overdue", due: "2026-06-17", ext: "", wantAOG: false},
{name: "due date future", due: "2026-06-20", ext: "", wantAOG: false},
{name: "ext date extends beyond passed due", due: "2026-06-10", ext: "2026-06-25", wantAOG: false},
{name: "past ext date", due: "2026-06-10", ext: "2026-06-12", wantAOG: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ev, err := evaluateInspectionDue(p, "MAIN1", tc.due, tc.ext, 0, true, now)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ev.AOG != tc.wantAOG {
t.Fatalf("AOG = %v (reason %q), want %v", ev.AOG, ev.Reason, tc.wantAOG)
}
})
}
}
func TestEvaluateInspectionDueInvalid(t *testing.T) {
p := nextdue.NewParser()
if _, err := evaluateInspectionDue(p, "A/F", "100 FH", "", 0, true, time.Now()); err == nil {
t.Fatal("expected parse error for due '100 FH'")
}
if _, err := evaluateInspectionDue(p, "A/F", "100", "bad ext", 0, true, time.Now()); err == nil {
t.Fatal("expected parse error for ext 'bad ext'")
}
}
func TestEvaluateInspectionDueNoUsageData(t *testing.T) {
p := nextdue.NewParser()
now := time.Date(2026, 6, 17, 8, 0, 0, 0, time.UTC)
ev, err := evaluateInspectionDue(p, "A/F", "100", "", 150, false, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if ev.AOG {
t.Fatal("hours due must not ground when there is no usage data yet")
}
evDate, err := evaluateInspectionDue(p, "MAIN1", "2026-06-10", "", 0, false, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if !evDate.AOG {
t.Fatal("a passed date due should still ground regardless of usage data")
}
}

View File

@@ -0,0 +1,286 @@
package service
import (
"context"
"testing"
"time"
fleethistory "wucher/internal/domain/fleet_history"
fleetstatus "wucher/internal/domain/fleet_status"
"wucher/internal/domain/helicopter"
helicopterusage "wucher/internal/domain/helicopter_usage"
)
type stubFleetRepo struct {
latest map[string]*fleetstatus.FleetStatus
missionHeli []byte
created *fleetstatus.FleetStatus
updated *fleetstatus.FleetStatus
deletedID []byte
servicedID []byte
}
func (s *stubFleetRepo) Create(_ context.Context, row *fleetstatus.FleetStatus) error {
s.created = row
return nil
}
func (s *stubFleetRepo) Update(_ context.Context, row *fleetstatus.FleetStatus) error {
s.updated = row
return nil
}
func (s *stubFleetRepo) Delete(_ context.Context, id, _ []byte) error { s.deletedID = id; return nil }
func (s *stubFleetRepo) GetByID(_ context.Context, _ []byte) (*fleetstatus.FleetStatus, error) {
return s.created, nil
}
func (s *stubFleetRepo) List(_ context.Context, _, _ string, _, _ int) ([]fleetstatus.FleetStatus, int64, error) {
return nil, 0, nil
}
func (s *stubFleetRepo) LatestByHelicopterIDs(_ context.Context, _ [][]byte) (map[string]*fleetstatus.FleetStatus, error) {
return s.latest, nil
}
func (s *stubFleetRepo) MarkServiced(_ context.Context, id []byte, _ time.Time, _ []byte) error {
s.servicedID = id
return nil
}
func (s *stubFleetRepo) ListServiceHistoryByHelicopterID(_ context.Context, _ []byte, _, _ int) ([]fleetstatus.FleetStatusServiceLog, int64, error) {
return nil, 0, nil
}
func (s *stubFleetRepo) HelicopterIDByMissionID(_ context.Context, _ []byte) ([]byte, error) {
return s.missionHeli, nil
}
func (s *stubFleetRepo) HelicopterIDsWithFleetStatus(_ context.Context) ([][]byte, error) {
out := make([][]byte, 0, len(s.latest))
for k := range s.latest {
out = append(out, []byte(k))
}
return out, nil
}
type stubFleetHelis struct {
heli *helicopter.Helicopter
updated *helicopter.Helicopter
}
func (s *stubFleetHelis) GetByID(_ context.Context, _ []byte) (*helicopter.Helicopter, error) {
return s.heli, nil
}
func (s *stubFleetHelis) Update(_ context.Context, h *helicopter.Helicopter) error {
s.updated = h
return nil
}
type stubFleetUsage struct {
usage *helicopterusage.HelicopterUsage
}
func (s *stubFleetUsage) GetByHelicopterID(_ context.Context, _ []byte) (*helicopterusage.HelicopterUsage, error) {
return s.usage, nil
}
func (s *stubFleetUsage) ReviseManualTotals(_ context.Context, _ []byte, _ helicopterusage.ManualSummaryInput, _ []byte) error {
return nil
}
func fleetUsage(airframeHours float64) *stubFleetUsage {
return &stubFleetUsage{usage: &helicopterusage.HelicopterUsage{TotalAirframeHours: airframeHours}}
}
type stubHistoryRepo struct{ recorded int }
func (s *stubHistoryRepo) Create(_ context.Context, _ *fleethistory.FleetHistory) error {
s.recorded++
return nil
}
func (s *stubHistoryRepo) ListByHelicopter(_ context.Context, _ []byte, _, _ int) ([]fleethistory.FleetHistory, int64, error) {
return nil, 0, nil
}
func (s *stubHistoryRepo) HelicopterIDByInspection(_ context.Context, _ []byte) ([]byte, error) {
return nil, nil
}
func newHistory() *FleetHistoryService { return NewFleetHistoryService(&stubHistoryRepo{}) }
func heliID() []byte { return []byte("0123456789abcdef") }
func fleetWith(schedDue string) *fleetstatus.FleetStatus {
return &fleetstatus.FleetStatus{
ID: []byte("fedcba9876543210"),
HelicopterID: heliID(),
MaintenanceSchedules: []fleetstatus.MaintenanceSchedule{
{InspectionType: fleetstatus.InspectionTypeAF, Due: schedDue},
},
}
}
func TestSyncAutomaticAOG_SetWhenOverdue(t *testing.T) {
row := fleetWith("100")
repo := &stubFleetRepo{
latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row},
}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}}
svc := NewFleetStatusService(repo, nil, helis, nil).WithHistory(newHistory()).WithUsage(fleetUsage(150))
if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil {
t.Fatalf("err: %v", err)
}
if helis.updated == nil || !helis.updated.AirOnGround {
t.Fatal("expected helicopter set AOG (150h > 100h due)")
}
if helis.updated.AOGSource != helicopter.AOGSourceAuto {
t.Fatalf("expected auto AOG source, got %q", helis.updated.AOGSource)
}
}
func TestSyncAutomaticAOG_NotOverdueNotGrounded(t *testing.T) {
row := fleetWith("100")
repo := &stubFleetRepo{
latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row},
}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}}
svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(fleetUsage(50))
if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil {
t.Fatalf("err: %v", err)
}
if helis.updated != nil {
t.Fatal("not overdue and not previously auto-AOG: should not touch helicopter")
}
}
func TestSyncAutomaticAOG_NoUsageDataNotGrounded(t *testing.T) {
row := fleetWith("100")
repo := &stubFleetRepo{
latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row},
}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}}
svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(&stubFleetUsage{usage: nil})
if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil {
t.Fatalf("err: %v", err)
}
if helis.updated != nil {
t.Fatal("fresh aircraft with no usage data must not be auto-AOG on an hours due")
}
}
func TestSyncAutomaticAOG_ClearWhenNoLongerOverdue(t *testing.T) {
row := fleetWith("100")
repo := &stubFleetRepo{
latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row},
}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{
ID: heliID(), NR1: true, NR2: true,
AirOnGround: true, AOGSource: helicopter.AOGSourceAuto, AOGReason: "[auto_next_due] A/F due hours reached",
}}
svc := NewFleetStatusService(repo, nil, helis, nil).WithHistory(newHistory()).WithUsage(fleetUsage(50))
if err := svc.RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil {
t.Fatalf("err: %v", err)
}
if helis.updated == nil || helis.updated.AirOnGround {
t.Fatal("auto-AOG should be cleared once no longer overdue")
}
}
func TestFleetStatusCreateUpdateGet(t *testing.T) {
repo := &stubFleetRepo{}
svc := NewFleetStatusService(repo, nil, nil, nil) // attachments nil -> simple path
ctx := context.Background()
row := &fleetstatus.FleetStatus{HelicopterID: heliID()}
if err := svc.Create(ctx, row); err != nil || repo.created != row {
t.Fatalf("Create: %v", err)
}
if err := svc.Update(ctx, row); err != nil || repo.updated != row {
t.Fatalf("Update: %v", err)
}
if _, err := svc.GetByID(ctx, heliID()); err != nil {
t.Fatalf("GetByID: %v", err)
}
}
func TestRecalculateAOGByMission(t *testing.T) {
row := fleetWith("100")
repo := &stubFleetRepo{
latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row},
missionHeli: heliID(),
}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}}
svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(fleetUsage(150))
if err := svc.RecalculateAOGByMission(context.Background(), []byte("mission0123456789")); err != nil {
t.Fatalf("err: %v", err)
}
if helis.updated == nil || !helis.updated.AirOnGround {
t.Fatal("mission recalc should set AOG")
}
// empty mission id -> no-op
if err := svc.RecalculateAOGByMission(context.Background(), nil); err != nil {
t.Fatalf("nil mission err: %v", err)
}
// no fleet status for helicopter -> no-op
if err := NewFleetStatusService(&stubFleetRepo{}, nil, helis, nil).RecalculateAOGByHelicopter(context.Background(), heliID()); err != nil {
t.Fatalf("no-fleet-status err: %v", err)
}
}
func TestRecalculateAllAOG(t *testing.T) {
row := fleetWith("100")
repo := &stubFleetRepo{
latest: map[string]*fleetstatus.FleetStatus{string(heliID()): row},
}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: true}}
svc := NewFleetStatusService(repo, nil, helis, nil).WithUsage(fleetUsage(150))
n, err := svc.RecalculateAllAOG(context.Background())
if err != nil {
t.Fatalf("err: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 helicopter recomputed, got %d", n)
}
if helis.updated == nil || !helis.updated.AirOnGround {
t.Fatal("overdue helicopter should have been set AOG by the sweep")
}
}
func TestGetSummaryByHelicopterID_EngineGating(t *testing.T) {
usage := &stubFleetUsage{usage: &helicopterusage.HelicopterUsage{
TotalAirframeHours: 6620,
TotalEngine1Hours: 8011,
TotalEngine2Hours: 5981,
}}
helis := &stubFleetHelis{heli: &helicopter.Helicopter{ID: heliID(), NR1: true, NR2: false}}
svc := NewFleetStatusService(&stubFleetRepo{}, nil, helis, nil).WithUsage(usage)
summary, err := svc.GetSummaryByHelicopterID(context.Background(), heliID())
if err != nil {
t.Fatalf("err: %v", err)
}
assertFloatPtr(t, "airframe", summary.Airframe.Hours, ptr(6620.0))
assertFloatPtr(t, "engine1", summary.Engine1.Hours, ptr(8011.0))
if summary.Engine2.Hours != nil {
t.Fatal("engine 2 absent (NR2 false) -> hours must be nil")
}
}
func TestFleetStatusSimpleDelegators(t *testing.T) {
repo := &stubFleetRepo{}
svc := NewFleetStatusService(repo, nil, nil, nil).WithHistory(nil)
ctx := context.Background()
if err := svc.Delete(ctx, []byte("id"), nil); err != nil || string(repo.deletedID) != "id" {
t.Fatalf("Delete: %v", err)
}
if _, _, err := svc.List(ctx, "", "", 0, 0); err != nil {
t.Fatalf("List: %v", err)
}
if _, err := svc.LatestByHelicopterIDs(ctx, [][]byte{heliID()}); err != nil {
t.Fatalf("LatestByHelicopterIDs: %v", err)
}
if _, _, err := svc.ListServiceHistoryByHelicopterID(ctx, heliID(), 0, 0); err != nil {
t.Fatalf("ListServiceHistory: %v", err)
}
if err := svc.MarkServiced(ctx, []byte("ms"), time.Time{}, nil); err != nil || string(repo.servicedID) != "ms" {
t.Fatalf("MarkServiced: %v", err)
}
}

View File

@@ -0,0 +1,95 @@
package service
import (
"context"
"fmt"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
filemanager "wucher/internal/domain/file_manager"
fleetstatus "wucher/internal/domain/fleet_status"
"wucher/internal/domain/helicopter"
helicopterusage "wucher/internal/domain/helicopter_usage"
"wucher/internal/repository/mysql"
)
// TestFleetStatusUpdateAcceptsNegativeSummaryAndBareSchedule guards the full service path
// behind PATCH /fleet-status/update/{id}: negative summary values + an A/F schedule with
// empty type/due/ext must persist (incl. syncAutomaticAOG, which the repo-level test skips).
func TestFleetStatusUpdateAcceptsNegativeSummaryAndBareSchedule(t *testing.T) {
dsn := fmt.Sprintf("file:fleet_update_repro_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{NowFunc: func() time.Time { return time.Now().UTC() }})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&filemanager.Folder{}, &filemanager.File{}, &filemanager.Attachment{},
&helicopter.Helicopter{},
&fleetstatus.FleetStatus{}, &fleetstatus.MaintenanceSchedule{}, &fleetstatus.FleetStatusFile{},
&helicopterusage.HelicopterUsage{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
ctx := context.Background()
heliRepo := mysql.NewHelicopterRepository(db)
heli := &helicopter.Helicopter{Designation: "H1", Identifier: "PK-H1", Type: "Twin", NR1: true, NR2: true}
if err := heliRepo.Create(ctx, heli); err != nil {
t.Fatalf("create heli: %v", err)
}
fsRepo := mysql.NewFleetStatusRepository(db)
fs := &fleetstatus.FleetStatus{HelicopterID: heli.ID, Status: fleetstatus.StatusActive}
if err := fsRepo.Create(ctx, fs); err != nil {
t.Fatalf("create fs: %v", err)
}
usageSvc := NewHelicopterUsageService(mysql.NewHelicopterUsageRepository(db))
svc := NewFleetStatusService(fsRepo, nil, NewHelicopterService(heliRepo), nil).WithUsage(usageSvc)
// Handler builds this row from the patch: full-replace schedules with one A/F entry.
fs.MaintenanceSchedules = []fleetstatus.MaintenanceSchedule{
{FleetStatusID: fs.ID, InspectionType: "A/F", Type: "", Due: "", Ext: ""},
}
fs.UpdatedBy = heli.ID // any actor
if err := svc.Update(ctx, fs); err != nil {
t.Fatalf("REPRO: FleetStatusService.Update FAILED: %v", err)
}
t.Log("FleetStatusService.Update OK (incl syncAutomaticAOG)")
f := func(v float64) *float64 { return &v }
if err := svc.ReviseSummary(ctx, heli.ID, helicopterusage.ManualSummaryInput{
AirframeHours: f(1234), AirframeCycles: f(-1),
Engine1Hours: f(1234), Engine1GpcNgN1: f(23.23), Engine1PtcNfN2: f(23.59), Engine1Ccc: f(-8),
Engine2Hours: f(234), Engine2GpcNgN1: f(123), Engine2PtcNfN2: f(1230), Engine2Ccc: f(123),
Landing: f(1), FlightReport: f(1), HookRelease: f(1), RotorBrakeCycle: f(1),
}, heli.ID); err != nil {
t.Fatalf("REPRO: ReviseSummary FAILED: %v", err)
}
got, _ := usageSvc.GetByHelicopterID(ctx, heli.ID)
t.Logf("ReviseSummary OK: cycles=%v ccc1=%v ccc2=%v", got.TotalAirframeCycles, got.TotalEngine1Ccc, got.TotalEngine2Ccc)
// The fleet GET reads the summary via GetSummaryByHelicopterID — verify it reflects the patch.
summary, err := svc.GetSummaryByHelicopterID(ctx, heli.ID)
if err != nil {
t.Fatalf("GetSummaryByHelicopterID: %v", err)
}
if summary.Airframe.Hours == nil || *summary.Airframe.Hours != 1234 {
t.Fatalf("summary airframe hours = %v, want 1234 (patch not reflected)", summary.Airframe.Hours)
}
if summary.Engine1.Hours == nil || *summary.Engine1.Hours != 1234 {
t.Fatalf("summary engine1 hours = %v, want 1234", summary.Engine1.Hours)
}
// Second PATCH with a NEW value — the "ga terganti" (value doesn't change) scenario.
if err := svc.ReviseSummary(ctx, heli.ID, helicopterusage.ManualSummaryInput{AirframeHours: f(9999)}, heli.ID); err != nil {
t.Fatalf("second ReviseSummary: %v", err)
}
summary2, _ := svc.GetSummaryByHelicopterID(ctx, heli.ID)
if summary2.Airframe.Hours == nil || *summary2.Airframe.Hours != 9999 {
t.Fatalf("second patch: airframe hours = %v, want 9999 (VALUE DID NOT CHANGE)", summary2.Airframe.Hours)
}
t.Logf("both patches reflected in GET summary: first af.hours=1234, second=%v", *summary2.Airframe.Hours)
}

View File

@@ -0,0 +1,332 @@
package service
import (
"context"
"strings"
afterflightinspection "wucher/internal/domain/after_flight_inspection"
flightdomain "wucher/internal/domain/flight"
flightdata "wucher/internal/domain/flight_data"
fmreport "wucher/internal/domain/fm_report"
sharedconst "wucher/internal/shared/const"
"wucher/internal/shared/pkg/uuidv7"
)
type aogRecalculator interface {
RecalculateAOGByMission(ctx context.Context, missionID []byte) error
}
type missionFlightResolver interface {
FlightIDByMissionID(ctx context.Context, missionID []byte) ([]byte, error)
}
type fmReportLookup interface {
GetByFlightID(ctx context.Context, flightID []byte) (*fmreport.Report, error)
}
type flightLookup interface {
GetByID(context.Context, []byte) (*flightdomain.Flight, error)
}
type afterFlightInspectionLookup interface {
GetByFlightInspectionID(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error)
}
type FlightDataService struct {
repo flightdata.Repository
aog aogRecalculator
mission missionFlightResolver
flight flightLookup
fmReports fmReportLookup
afterFlight afterFlightInspectionLookup
}
func NewFlightDataService(repo flightdata.Repository) *FlightDataService {
return &FlightDataService{repo: repo}
}
func (s *FlightDataService) WithAOGRecalculator(a aogRecalculator) *FlightDataService {
s.aog = a
return s
}
func (s *FlightDataService) WithMissionResolver(resolver missionFlightResolver) *FlightDataService {
s.mission = resolver
return s
}
func (s *FlightDataService) WithFlightLookup(lookup flightLookup) *FlightDataService {
s.flight = lookup
return s
}
func (s *FlightDataService) WithFMReportLookup(lookup fmReportLookup) *FlightDataService {
s.fmReports = lookup
return s
}
func (s *FlightDataService) WithAfterFlightInspectionLookup(lookup afterFlightInspectionLookup) *FlightDataService {
s.afterFlight = lookup
return s
}
func (s *FlightDataService) recalcAOG(ctx context.Context, missionID []byte) {
if s.aog == nil || len(missionID) == 0 {
return
}
_ = s.aog.RecalculateAOGByMission(ctx, missionID)
}
func (s *FlightDataService) Create(ctx context.Context, row *flightdata.FlightData) error {
if row == nil {
return flightdata.ErrRequired
}
if err := validateFlightDataLocationPair(row.FromICAOID, row.FromHospitalID, true); err != nil {
return err
}
if err := validateFlightDataLocationPair(row.ToICAOID, row.ToHospitalID, false); err != nil {
return err
}
if err := s.canCreateForMission(ctx, row.MissionID); err != nil {
return err
}
row.Status = flightdata.StatusInProgress
if err := s.repo.Create(ctx, row); err != nil {
return err
}
s.recalcAOG(ctx, row.MissionID)
if err := s.syncStatus(ctx, row.ID); err != nil {
return err
}
return nil
}
func (s *FlightDataService) CreatePlaceholder(ctx context.Context, row *flightdata.FlightData) error {
if row == nil {
return flightdata.ErrRequired
}
if err := s.canCreateForMission(ctx, row.MissionID); err != nil {
return err
}
row.Status = flightdata.StatusInProgress
if err := s.repo.Create(ctx, row); err != nil {
return err
}
return nil
}
func (s *FlightDataService) Update(ctx context.Context, row *flightdata.FlightData) error {
if row == nil {
return flightdata.ErrRequired
}
if err := validateFlightDataLocationPair(row.FromICAOID, row.FromHospitalID, true); err != nil {
return err
}
if err := validateFlightDataLocationPair(row.ToICAOID, row.ToHospitalID, false); err != nil {
return err
}
if err := s.ensureEditable(ctx, row.MissionID); err != nil {
return err
}
if err := s.repo.Update(ctx, row); err != nil {
return err
}
s.recalcAOG(ctx, row.MissionID)
if err := s.syncStatus(ctx, row.ID); err != nil {
return err
}
return nil
}
func (s *FlightDataService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
var missionID []byte
if row, err := s.repo.GetByID(ctx, id); err == nil && row != nil {
missionID = row.MissionID
if err := s.ensureAfterFlightInspectionNotLocked(ctx, missionID); err != nil {
return err
}
if err := s.ensureEditable(ctx, missionID); err != nil {
return err
}
}
if err := s.repo.Delete(ctx, id, deletedBy); err != nil {
return err
}
s.recalcAOG(ctx, missionID)
return nil
}
func (s *FlightDataService) GetByID(ctx context.Context, id []byte) (*flightdata.FlightData, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FlightDataService) GetByFlightID(ctx context.Context, flightID []byte) (*flightdata.FlightData, error) {
return s.repo.GetByFlightID(ctx, flightID)
}
func (s *FlightDataService) GetByMissionID(ctx context.Context, missionID []byte) (*flightdata.FlightData, error) {
return s.repo.GetByMissionID(ctx, missionID)
}
func (s *FlightDataService) ListByMissionID(ctx context.Context, missionID []byte) ([]flightdata.FlightData, error) {
return s.repo.ListByMissionID(ctx, missionID)
}
func (s *FlightDataService) List(ctx context.Context, date string, missionID, flightDataID []byte, limit, offset int) ([]flightdata.FlightData, int64, error) {
return s.repo.List(ctx, date, missionID, flightDataID, limit, offset)
}
func (s *FlightDataService) GetSPODetailsByFlightDataID(ctx context.Context, flightDataID []byte) (*flightdata.SPODetails, error) {
return s.repo.GetSPODetailsByFlightDataID(ctx, flightDataID)
}
func (s *FlightDataService) UpsertSPODetails(ctx context.Context, flightDataID []byte, data *flightdata.SPOUpsertData) error {
return s.repo.UpsertSPODetails(ctx, flightDataID, data)
}
func (s *FlightDataService) CanCreateForMission(ctx context.Context, missionID []byte) error {
return s.canCreateForMission(ctx, missionID)
}
func (s *FlightDataService) canCreateForMission(ctx context.Context, missionID []byte) error {
if err := s.ensureAfterFlightInspectionNotLocked(ctx, missionID); err != nil {
return err
}
flightID, err := s.flightIDByMissionID(ctx, missionID)
if err != nil {
return err
}
if len(flightID) != 16 || s.fmReports == nil {
return nil
}
report, err := s.fmReports.GetByFlightID(ctx, flightID)
if err != nil {
return err
}
if report != nil {
return flightdata.ErrCreateBlocked
}
return nil
}
func (s *FlightDataService) ensureEditable(ctx context.Context, missionID []byte) error {
flightID, err := s.flightIDByMissionID(ctx, missionID)
if err != nil {
return err
}
if len(flightID) != 16 || s.fmReports == nil {
return nil
}
report, err := s.fmReports.GetByFlightID(ctx, flightID)
if err != nil {
return err
}
if report != nil && report.CompletedAt != nil {
return flightdata.ErrLocked
}
return nil
}
func (s *FlightDataService) ensureAfterFlightInspectionNotLocked(ctx context.Context, missionID []byte) error {
flightID, err := s.flightIDByMissionID(ctx, missionID)
if err != nil {
return err
}
if len(flightID) != 16 || s.flight == nil || s.afterFlight == nil {
return nil
}
flightRow, err := s.flight.GetByID(ctx, flightID)
if err != nil {
return err
}
if flightRow == nil || flightRow.Takeover == nil || flightRow.Takeover.ReserveAc == nil || len(flightRow.Takeover.ReserveAc.InspectionID) != 16 {
return nil
}
afterRow, err := s.afterFlight.GetByFlightInspectionID(ctx, flightRow.Takeover.ReserveAc.InspectionID)
if err != nil {
return err
}
if afterRow != nil {
return flightdata.ErrLockedAfterFlightInspection
}
return nil
}
func (s *FlightDataService) flightIDByMissionID(ctx context.Context, missionID []byte) ([]byte, error) {
if len(missionID) != 16 || s.mission == nil {
return nil, nil
}
flightID, err := s.mission.FlightIDByMissionID(ctx, missionID)
if err != nil {
return nil, err
}
return flightID, nil
}
func (s *FlightDataService) AttachmentReferenceType() string {
return sharedconst.FlightDataResourceType
}
func (s *FlightDataService) AttachmentReferenceID(row *flightdata.FlightData) string {
if row == nil {
return ""
}
id, err := uuidv7.BytesToString(row.ID)
if err != nil {
return ""
}
return id
}
func (s *FlightDataService) syncStatus(ctx context.Context, id []byte) error {
if len(id) != 16 {
return nil
}
row, err := s.repo.GetByID(ctx, id)
if err != nil || row == nil {
return err
}
if row.Mission == nil && len(row.MissionID) == 16 {
if byMissionID, lookupErr := s.repo.GetByMissionID(ctx, row.MissionID); lookupErr == nil && byMissionID != nil {
row = byMissionID
}
}
details, err := s.loadSPODetails(ctx, row)
if err != nil {
return err
}
desired := flightdata.DeriveStatus(row, details)
if strings.EqualFold(strings.TrimSpace(row.Status), desired) {
return nil
}
row.Status = desired
return s.repo.Update(ctx, row)
}
func (s *FlightDataService) loadSPODetails(ctx context.Context, row *flightdata.FlightData) (*flightdata.SPODetails, error) {
if row == nil || row.Mission == nil {
return nil, nil
}
if !strings.EqualFold(strings.TrimSpace(row.Mission.Type), sharedconst.MissionTypeSPO) {
return nil, nil
}
details, err := s.repo.GetSPODetailsByFlightDataID(ctx, row.ID)
if err != nil {
return nil, err
}
return details, nil
}
func validateFlightDataLocationPair(a, b []byte, isOrigin bool) error {
hasA := len(a) > 0
hasB := len(b) > 0
if hasA == hasB {
if isOrigin {
return flightdata.ErrOriginLocationReference
}
return flightdata.ErrDestinationLocationReference
}
return nil
}

View File

@@ -0,0 +1,468 @@
package service
import (
"context"
"errors"
"testing"
"time"
afterflightinspection "wucher/internal/domain/after_flight_inspection"
flightdomain "wucher/internal/domain/flight"
flightdata "wucher/internal/domain/flight_data"
fmreport "wucher/internal/domain/fm_report"
missiondomain "wucher/internal/domain/mission"
reserveac "wucher/internal/domain/reserve_ac"
takeoverdomain "wucher/internal/domain/takeover"
sharedconst "wucher/internal/shared/const"
"wucher/internal/shared/pkg/uuidv7"
)
type flightDataRepoStub struct {
createFn func(context.Context, *flightdata.FlightData) error
updateFn func(context.Context, *flightdata.FlightData) error
getByIDFn func(context.Context, []byte) (*flightdata.FlightData, error)
getByMissionIDFn func(context.Context, []byte) (*flightdata.FlightData, error)
getSPODetailsByFlightID func(context.Context, []byte) (*flightdata.SPODetails, error)
}
type missionResolverStub struct {
flightID []byte
err error
}
func (m missionResolverStub) FlightIDByMissionID(context.Context, []byte) ([]byte, error) {
return append([]byte(nil), m.flightID...), m.err
}
type fmReportLookupStub struct {
report *fmreport.Report
err error
}
type flightLookupStub struct {
flight *flightdomain.Flight
err error
}
type afterFlightInspectionLookupStub struct {
row *afterflightinspection.AfterFlightInspection
err error
}
func (s fmReportLookupStub) GetByFlightID(context.Context, []byte) (*fmreport.Report, error) {
return s.report, s.err
}
func (s flightLookupStub) GetByID(context.Context, []byte) (*flightdomain.Flight, error) {
return s.flight, s.err
}
func (s afterFlightInspectionLookupStub) GetByFlightInspectionID(context.Context, []byte) (*afterflightinspection.AfterFlightInspection, error) {
return s.row, s.err
}
func (r *flightDataRepoStub) Create(ctx context.Context, row *flightdata.FlightData) error {
if r.createFn != nil {
return r.createFn(ctx, row)
}
return nil
}
func (r *flightDataRepoStub) Update(ctx context.Context, row *flightdata.FlightData) error {
if r.updateFn != nil {
return r.updateFn(ctx, row)
}
return nil
}
func (r *flightDataRepoStub) Delete(context.Context, []byte, []byte) error { return nil }
func (r *flightDataRepoStub) GetByID(ctx context.Context, id []byte) (*flightdata.FlightData, error) {
if r.getByIDFn != nil {
return r.getByIDFn(ctx, id)
}
return nil, nil
}
func (r *flightDataRepoStub) GetByFlightID(context.Context, []byte) (*flightdata.FlightData, error) {
return nil, nil
}
func (r *flightDataRepoStub) GetByMissionID(ctx context.Context, id []byte) (*flightdata.FlightData, error) {
if r.getByMissionIDFn != nil {
return r.getByMissionIDFn(ctx, id)
}
return nil, nil
}
func (r *flightDataRepoStub) ListByMissionID(context.Context, []byte) ([]flightdata.FlightData, error) {
return nil, nil
}
func (r *flightDataRepoStub) List(context.Context, string, []byte, []byte, int, int) ([]flightdata.FlightData, int64, error) {
return nil, 0, nil
}
func (r *flightDataRepoStub) GetSPODetailsByFlightDataID(ctx context.Context, id []byte) (*flightdata.SPODetails, error) {
if r.getSPODetailsByFlightID != nil {
return r.getSPODetailsByFlightID(ctx, id)
}
return nil, nil
}
func (r *flightDataRepoStub) UpsertSPODetails(context.Context, []byte, *flightdata.SPOUpsertData) error {
return nil
}
func TestFlightDataServiceCreateSyncsCompleteStatus(t *testing.T) {
var updates []*flightdata.FlightData
rowID := []byte("019ecc1111111111")
repo := &flightDataRepoStub{
getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) {
loaded := &flightdata.FlightData{
ID: id,
MissionID: []byte("1234567890123456"),
CoPilotID: []byte("2345678901234567"),
FromICAOID: []byte("3456789012345678"),
ToHospitalID: []byte("4567890123456789"),
Mission: &missiondomain.Mission{Type: sharedconst.MissionTypeCAT},
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
Status: flightdata.StatusInProgress,
}
if got := flightdata.DeriveStatus(loaded, nil); got != flightdata.StatusCompleted {
t.Fatalf("expected derived status complete, got %q", got)
}
return loaded, nil
},
updateFn: func(_ context.Context, row *flightdata.FlightData) error {
updates = append(updates, row)
return nil
},
}
svc := NewFlightDataService(repo)
if err := svc.syncStatus(context.Background(), rowID); err != nil {
t.Fatalf("syncStatus returned error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected one status sync update, got %d", len(updates))
}
if updates[0].Status != flightdata.StatusCompleted {
t.Fatalf("expected sync update to complete status, got %q", updates[0].Status)
}
}
func TestFlightDataServiceCreateKeepsSPOIncompleteStatus(t *testing.T) {
rowID := []byte("019ecc1122222222")
repo := &flightDataRepoStub{
getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) {
loaded := &flightdata.FlightData{
ID: id,
MissionID: []byte("1234567890123456"),
CoPilotID: []byte("2345678901234567"),
FromICAOID: []byte("3456789012345678"),
ToHospitalID: []byte("4567890123456789"),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
Mission: &missiondomain.Mission{Type: sharedconst.MissionTypeSPO},
Status: flightdata.StatusInProgress,
}
if got := flightdata.DeriveStatus(loaded, nil); got != flightdata.StatusInProgress {
t.Fatalf("expected derived status on_progress, got %q", got)
}
return loaded, nil
},
getSPODetailsByFlightID: func(context.Context, []byte) (*flightdata.SPODetails, error) {
return &flightdata.SPODetails{}, nil
},
}
svc := NewFlightDataService(repo)
if err := svc.syncStatus(context.Background(), rowID); err != nil {
t.Fatalf("syncStatus returned error: %v", err)
}
}
func TestFlightDataServiceCreateSyncsCompleteStatusWithoutRotorBrakeCycleForCATAndNCO(t *testing.T) {
for _, tc := range []struct {
name string
typ string
}{
{name: "cat", typ: sharedconst.MissionTypeCAT},
{name: "nco", typ: sharedconst.MissionTypeNCO},
} {
t.Run(tc.name, func(t *testing.T) {
var updates []*flightdata.FlightData
rowID := []byte("019ecc1144444444")
repo := &flightDataRepoStub{
getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) {
loaded := &flightdata.FlightData{
ID: id,
MissionID: []byte("1234567890123456"),
CoPilotID: []byte("2345678901234567"),
FromICAOID: []byte("3456789012345678"),
ToHospitalID: []byte("4567890123456789"),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
Mission: &missiondomain.Mission{Type: tc.typ},
Status: flightdata.StatusInProgress,
}
if got := flightdata.DeriveStatus(loaded, nil); got != flightdata.StatusCompleted {
t.Fatalf("expected derived status complete, got %q", got)
}
return loaded, nil
},
updateFn: func(_ context.Context, row *flightdata.FlightData) error {
updates = append(updates, row)
return nil
},
}
svc := NewFlightDataService(repo)
if err := svc.syncStatus(context.Background(), rowID); err != nil {
t.Fatalf("syncStatus returned error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected one status sync update, got %d", len(updates))
}
if updates[0].Status != flightdata.StatusCompleted {
t.Fatalf("expected sync update to complete status, got %q", updates[0].Status)
}
})
}
}
func TestFlightDataServiceSyncStatusFallsBackToMissionLookup(t *testing.T) {
var updates []*flightdata.FlightData
rowID := []byte("019ecc1133333333")
missionID := []byte("1234567890123456")
repo := &flightDataRepoStub{
getByIDFn: func(_ context.Context, id []byte) (*flightdata.FlightData, error) {
return &flightdata.FlightData{
ID: id,
MissionID: missionID,
CoPilotID: []byte("2345678901234567"),
FromHospitalID: []byte("3456789012345678"),
ToHospitalID: []byte("4567890123456789"),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
Status: flightdata.StatusInProgress,
}, nil
},
getByMissionIDFn: func(_ context.Context, _ []byte) (*flightdata.FlightData, error) {
return &flightdata.FlightData{
ID: rowID,
MissionID: missionID,
CoPilotID: []byte("2345678901234567"),
FromHospitalID: []byte("3456789012345678"),
ToHospitalID: []byte("4567890123456789"),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
Mission: &missiondomain.Mission{Type: sharedconst.MissionTypeCAT},
Status: flightdata.StatusInProgress,
}, nil
},
updateFn: func(_ context.Context, row *flightdata.FlightData) error {
updates = append(updates, row)
return nil
},
}
svc := NewFlightDataService(repo)
if err := svc.syncStatus(context.Background(), rowID); err != nil {
t.Fatalf("syncStatus returned error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected one status sync update, got %d", len(updates))
}
if updates[0].Status != flightdata.StatusCompleted {
t.Fatalf("expected sync update to complete status, got %q", updates[0].Status)
}
}
func TestFlightDataServiceCreateBlockedWhenFMReportExists(t *testing.T) {
missionID := uuidv7.MustBytes()
repo := &flightDataRepoStub{
createFn: func(context.Context, *flightdata.FlightData) error {
t.Fatal("create should not be called when fm report already exists")
return nil
},
}
svc := NewFlightDataService(repo).
WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}).
WithFMReportLookup(fmReportLookupStub{report: &fmreport.Report{ID: uuidv7.MustBytes(), FlightID: uuidv7.MustBytes()}})
err := svc.Create(context.Background(), &flightdata.FlightData{
MissionID: missionID,
CoPilotID: uuidv7.MustBytes(),
FromHospitalID: uuidv7.MustBytes(),
ToICAOID: uuidv7.MustBytes(),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
})
if !errors.Is(err, flightdata.ErrCreateBlocked) {
t.Fatalf("expected create blocked, got %v", err)
}
}
func TestFlightDataServiceUpdateLockedWhenFMReportCompleted(t *testing.T) {
missionID := uuidv7.MustBytes()
repo := &flightDataRepoStub{
getByIDFn: func(context.Context, []byte) (*flightdata.FlightData, error) {
return &flightdata.FlightData{
ID: uuidv7.MustBytes(),
MissionID: missionID,
CoPilotID: uuidv7.MustBytes(),
FromHospitalID: uuidv7.MustBytes(),
ToICAOID: uuidv7.MustBytes(),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
}, nil
},
updateFn: func(context.Context, *flightdata.FlightData) error {
t.Fatal("update should not be called when fm report is completed")
return nil
},
}
completedAt := time.Now().UTC()
svc := NewFlightDataService(repo).
WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}).
WithFMReportLookup(fmReportLookupStub{report: &fmreport.Report{ID: uuidv7.MustBytes(), FlightID: uuidv7.MustBytes(), CompletedAt: &completedAt}})
err := svc.Update(context.Background(), &flightdata.FlightData{
ID: uuidv7.MustBytes(),
MissionID: missionID,
CoPilotID: uuidv7.MustBytes(),
FromHospitalID: uuidv7.MustBytes(),
ToICAOID: uuidv7.MustBytes(),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
})
if !errors.Is(err, flightdata.ErrLocked) {
t.Fatalf("expected locked error, got %v", err)
}
}
func TestFlightDataServiceCreateBlockedWhenAfterFlightInspectionExists(t *testing.T) {
missionID := uuidv7.MustBytes()
inspectionID := uuidv7.MustBytes()
repo := &flightDataRepoStub{
createFn: func(context.Context, *flightdata.FlightData) error {
t.Fatal("create should not be called when after flight inspection exists")
return nil
},
}
svc := NewFlightDataService(repo).
WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}).
WithFlightLookup(flightLookupStub{
flight: &flightdomain.Flight{
ID: uuidv7.MustBytes(),
Takeover: &takeoverdomain.TakeoverAc{
ReserveAc: &reserveac.ReserveAc{InspectionID: inspectionID},
},
},
}).
WithAfterFlightInspectionLookup(afterFlightInspectionLookupStub{row: &afterflightinspection.AfterFlightInspection{ID: uuidv7.MustBytes(), FlightInspectionID: inspectionID}})
err := svc.Create(context.Background(), &flightdata.FlightData{
MissionID: missionID,
CoPilotID: uuidv7.MustBytes(),
FromHospitalID: uuidv7.MustBytes(),
ToICAOID: uuidv7.MustBytes(),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
})
if !errors.Is(err, flightdata.ErrLockedAfterFlightInspection) {
t.Fatalf("expected after-flight inspection lock error, got %v", err)
}
}
func TestFlightDataServiceUpdateAllowedWhenAfterFlightInspectionExists(t *testing.T) {
missionID := uuidv7.MustBytes()
inspectionID := uuidv7.MustBytes()
updated := false
repo := &flightDataRepoStub{
getByIDFn: func(context.Context, []byte) (*flightdata.FlightData, error) {
return &flightdata.FlightData{
ID: uuidv7.MustBytes(),
MissionID: missionID,
CoPilotID: uuidv7.MustBytes(),
FromHospitalID: uuidv7.MustBytes(),
ToICAOID: uuidv7.MustBytes(),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
}, nil
},
updateFn: func(context.Context, *flightdata.FlightData) error {
updated = true
return nil
},
}
svc := NewFlightDataService(repo).
WithMissionResolver(missionResolverStub{flightID: uuidv7.MustBytes()}).
WithFlightLookup(flightLookupStub{
flight: &flightdomain.Flight{
ID: uuidv7.MustBytes(),
Takeover: &takeoverdomain.TakeoverAc{
ReserveAc: &reserveac.ReserveAc{InspectionID: inspectionID},
},
},
}).
WithAfterFlightInspectionLookup(afterFlightInspectionLookupStub{row: &afterflightinspection.AfterFlightInspection{ID: uuidv7.MustBytes(), FlightInspectionID: inspectionID}})
err := svc.Update(context.Background(), &flightdata.FlightData{
ID: uuidv7.MustBytes(),
MissionID: missionID,
CoPilotID: uuidv7.MustBytes(),
FromHospitalID: uuidv7.MustBytes(),
ToICAOID: uuidv7.MustBytes(),
FlightTakeOff: time.Now().UTC(),
FlightLanding: time.Now().UTC(),
FlightDuration: time.Hour,
LandingCount: 1,
RotorBrakeCycle: 1,
})
if err != nil {
t.Fatalf("expected update to be allowed, got %v", err)
}
if !updated {
t.Fatal("expected update to be called")
}
}
func TestFlightDataServiceUpdateRequiresRow(t *testing.T) {
svc := NewFlightDataService(&flightDataRepoStub{})
if err := svc.Update(context.Background(), nil); !errors.Is(err, flightdata.ErrRequired) {
t.Fatalf("expected ErrRequired, got %v", err)
}
}

View File

@@ -0,0 +1,59 @@
package service
import (
"context"
"strings"
flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
)
type FlightInspectionFileChecklistService struct {
repo flightinspectionfilechecklist.Repository
}
func NewFlightInspectionFileChecklistService(repo flightinspectionfilechecklist.Repository) *FlightInspectionFileChecklistService {
return &FlightInspectionFileChecklistService{repo: repo}
}
func (s *FlightInspectionFileChecklistService) Create(ctx context.Context, row *flightinspectionfilechecklist.FlightInspectionFileChecklist) error {
return s.repo.Create(ctx, row)
}
func (s *FlightInspectionFileChecklistService) Update(ctx context.Context, row *flightinspectionfilechecklist.FlightInspectionFileChecklist) error {
return s.repo.Update(ctx, row)
}
func (s *FlightInspectionFileChecklistService) Delete(ctx context.Context, id []byte) error {
return s.repo.Delete(ctx, id)
}
func (s *FlightInspectionFileChecklistService) GetByID(ctx context.Context, id []byte) (*flightinspectionfilechecklist.FlightInspectionFileChecklist, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FlightInspectionFileChecklistService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]flightinspectionfilechecklist.FlightInspectionFileChecklist, int64, error) {
return s.repo.List(ctx, filter, normalizeFlightInspectionFileChecklistSort(sort), limit, offset)
}
func (s *FlightInspectionFileChecklistService) ListByInspectionID(ctx context.Context, inspectionID []byte) ([]flightinspectionfilechecklist.FlightInspectionFileChecklist, error) {
return s.repo.ListByInspectionID(ctx, inspectionID)
}
func (s *FlightInspectionFileChecklistService) FlightInspectionExists(ctx context.Context, inspectionID []byte) (bool, error) {
return s.repo.FlightInspectionExists(ctx, inspectionID)
}
func (s *FlightInspectionFileChecklistService) HelicopterFileExists(ctx context.Context, helicopterFileID []byte) (bool, error) {
return s.repo.HelicopterFileExists(ctx, helicopterFileID)
}
func normalizeFlightInspectionFileChecklistSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortExpr("is_done ASC, created_at ASC", "is_done DESC, created_at DESC", "is_done"),
sortField("created_at"),
sortField("updated_at"),
); normalized != "" {
return normalized
}
return "created_at DESC"
}

View File

@@ -0,0 +1,51 @@
package service
import (
"context"
"strings"
"time"
flightinspection "wucher/internal/domain/flight_inspection"
)
type FlightInspectionService struct {
repo flightinspection.FlightInspectionRepository
}
func NewFlightInspectionService(repo flightinspection.FlightInspectionRepository) *FlightInspectionService {
return &FlightInspectionService{repo: repo}
}
func (s *FlightInspectionService) CreateDraft(ctx context.Context, row *flightinspection.FlightInspection) error {
row.Status = flightinspection.StatusDraft
row.InspectionDate = dateOnlyUTCService(row.InspectionDate)
return s.repo.Create(ctx, row)
}
func (s *FlightInspectionService) Update(ctx context.Context, row *flightinspection.FlightInspection) error {
row.InspectionDate = dateOnlyUTCService(row.InspectionDate)
return s.repo.Update(ctx, row)
}
func (s *FlightInspectionService) GetByID(ctx context.Context, id []byte) (*flightinspection.FlightInspection, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FlightInspectionService) List(ctx context.Context, sort string, limit, offset int) ([]flightinspection.FlightInspection, int64, error) {
return s.repo.List(ctx, normalizeFlightInspectionSort(sort), limit, offset)
}
func normalizeFlightInspectionSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortExpr("inspection_date ASC, created_at ASC", "inspection_date DESC, created_at DESC", "inspection_date"),
sortField("created_at"),
sortField("updated_at"),
); normalized != "" {
return normalized
}
return "inspection_date DESC, created_at DESC"
}
func dateOnlyUTCService(t time.Time) time.Time {
return time.Date(t.UTC().Year(), t.UTC().Month(), t.UTC().Day(), 0, 0, 0, 0, time.UTC)
}

View File

@@ -0,0 +1,151 @@
package service
import (
"context"
"fmt"
flightinspection "wucher/internal/domain/flight_inspection"
flightprepcheck "wucher/internal/domain/flight_prep_check"
"wucher/internal/shared/pkg/appctx"
)
type FlightPrepCheckService struct {
checkRepo flightprepcheck.Repository
inspectionRepo flightinspection.FlightInspectionRepository
}
func NewFlightPrepCheckService(
checkRepo flightprepcheck.Repository,
inspectionRepo flightinspection.FlightInspectionRepository,
) *FlightPrepCheckService {
return &FlightPrepCheckService{
checkRepo: checkRepo,
inspectionRepo: inspectionRepo,
}
}
func (s *FlightPrepCheckService) InitializeChecks(ctx context.Context, flightInspectionID []byte) (int, error) {
if len(flightInspectionID) != 16 {
return 0, &flightprepcheck.ValidationError{Status: 400, Title: "Invalid ID", Detail: "flight_inspection_id must be a valid UUID"}
}
inspection, err := s.inspectionRepo.GetByID(ctx, flightInspectionID)
if err != nil {
return 0, fmt.Errorf("failed to check flight inspection: %w", err)
}
if inspection == nil {
return 0, &flightprepcheck.ValidationError{Status: 404, Title: "Not found", Detail: "flight inspection not found"}
}
existing, err := s.checkRepo.GetByFlightInspectionID(ctx, flightInspectionID)
if err != nil {
return 0, fmt.Errorf("failed to get prep check: %w", err)
}
if existing != nil {
return 0, nil
}
row := &flightprepcheck.FlightPrepCheck{
FlightInspectionID: flightInspectionID,
}
if userID := appctx.GetUserID(ctx); userID != nil {
row.CreatedBy = userID
row.UpdatedBy = userID
}
if err := s.checkRepo.Create(ctx, row); err != nil {
return 0, fmt.Errorf("failed to create prep check: %w", err)
}
return 1, nil
}
func (s *FlightPrepCheckService) GetByFlightInspection(ctx context.Context, flightInspectionID []byte) (*flightprepcheck.FlightPrepCheck, error) {
if len(flightInspectionID) != 16 {
return nil, &flightprepcheck.ValidationError{Status: 400, Title: "Invalid ID", Detail: "flight_inspection_id must be a valid UUID"}
}
inspection, err := s.inspectionRepo.GetByID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to check flight inspection: %w", err)
}
if inspection == nil {
return nil, &flightprepcheck.ValidationError{Status: 404, Title: "Not found", Detail: "flight inspection not found"}
}
return s.checkRepo.GetByFlightInspectionID(ctx, flightInspectionID)
}
func (s *FlightPrepCheckService) UpdateCheck(ctx context.Context, checkID []byte, req *flightprepcheck.UpsertRequest) (*flightprepcheck.FlightPrepCheck, error) {
if len(checkID) != 16 {
return nil, &flightprepcheck.ValidationError{Status: 400, Title: "Invalid ID", Detail: "check_id must be a valid UUID"}
}
existing, err := s.checkRepo.GetByID(ctx, checkID)
if err != nil {
return nil, fmt.Errorf("failed to get prep check: %w", err)
}
if existing == nil {
return nil, &flightprepcheck.ValidationError{Status: 404, Title: "Not found", Detail: "flight prep check not found"}
}
applyFlightPrepCheckUpsert(existing, req)
if userID := appctx.GetUserID(ctx); userID != nil {
existing.UpdatedBy = userID
}
if err := s.checkRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update prep check: %w", err)
}
return existing, nil
}
func (s *FlightPrepCheckService) UpsertByFlightInspection(ctx context.Context, flightInspectionID []byte, req *flightprepcheck.UpsertRequest) (*flightprepcheck.FlightPrepCheck, error) {
if len(flightInspectionID) != 16 {
return nil, &flightprepcheck.ValidationError{Status: 400, Title: "Invalid ID", Detail: "flight_inspection_id must be a valid UUID"}
}
inspection, err := s.inspectionRepo.GetByID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to check flight inspection: %w", err)
}
if inspection == nil {
return nil, &flightprepcheck.ValidationError{Status: 404, Title: "Not found", Detail: "flight inspection not found"}
}
existing, err := s.checkRepo.GetByFlightInspectionID(ctx, flightInspectionID)
if err != nil {
return nil, fmt.Errorf("failed to get prep check: %w", err)
}
if existing == nil {
existing = &flightprepcheck.FlightPrepCheck{FlightInspectionID: flightInspectionID}
if userID := appctx.GetUserID(ctx); userID != nil {
existing.CreatedBy = userID
existing.UpdatedBy = userID
}
applyFlightPrepCheckUpsert(existing, req)
if err := s.checkRepo.Create(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to create prep check: %w", err)
}
return existing, nil
}
applyFlightPrepCheckUpsert(existing, req)
if userID := appctx.GetUserID(ctx); userID != nil {
existing.UpdatedBy = userID
}
if err := s.checkRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update prep check: %w", err)
}
return existing, nil
}
func applyFlightPrepCheckUpsert(row *flightprepcheck.FlightPrepCheck, req *flightprepcheck.UpsertRequest) {
if row == nil || req == nil {
return
}
if req.NOTAMBriefing != nil {
row.NOTAMBriefing = *req.NOTAMBriefing
}
if req.WeatherBriefing != nil {
row.WeatherBriefing = *req.WeatherBriefing
}
if req.OperationalFlightPlan != nil {
row.OperationalFlightPlan = *req.OperationalFlightPlan
}
}

View File

@@ -0,0 +1,92 @@
package service
import (
"context"
"strings"
"time"
"wucher/internal/domain/flight"
"wucher/internal/shared/pkg/missioncode"
)
type FlightService struct {
repo flight.FlightRepository
}
func NewFlightService(repo flight.FlightRepository) *FlightService {
return &FlightService{repo: repo}
}
func (s *FlightService) Create(ctx context.Context, row *flight.Flight) error {
if row != nil {
now := time.Now()
prefix := missioncode.BuildPrefix(now)
latest, err := s.repo.GetLatestMissionCodeByPrefix(ctx, prefix)
if err != nil {
return err
}
nextCode := missioncode.NextFromLatest(prefix, latest)
row.MissionCode = &nextCode
}
return s.repo.Create(ctx, row)
}
func (s *FlightService) Update(ctx context.Context, row *flight.Flight) error {
return s.repo.Update(ctx, row)
}
func (s *FlightService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *FlightService) GetByID(ctx context.Context, id []byte) (*flight.Flight, error) {
return s.repo.GetByID(ctx, id)
}
func (s *FlightService) ListByUserID(ctx context.Context, userID []byte, limit, offset int) ([]flight.Flight, int64, error) {
return s.repo.ListByUserID(ctx, userID, limit, offset)
}
func (s *FlightService) ListByCreatedBy(ctx context.Context, createdBy []byte, filter string, date string, sort string, limit, offset int) ([]flight.Flight, int64, error) {
return s.repo.ListByCreatedBy(ctx, createdBy, filter, date, normalizeFlightSort(sort), limit, offset)
}
func (s *FlightService) GetByReserveAcID(ctx context.Context, reserveAcID []byte) (*flight.Flight, error) {
return s.repo.GetByReserveAcID(ctx, reserveAcID)
}
func (s *FlightService) ListByReserveAcID(ctx context.Context, reserveAcID []byte) ([]flight.Flight, error) {
return s.repo.ListByReserveAcID(ctx, reserveAcID)
}
func (s *FlightService) GetByTakeoverAcID(ctx context.Context, takeoverAcID []byte) (*flight.Flight, error) {
return s.repo.GetByTakeoverAcID(ctx, takeoverAcID)
}
func (s *FlightService) ListByTakeoverAcIDs(ctx context.Context, takeoverAcIDs [][]byte) ([]flight.Flight, error) {
return s.repo.ListByTakeoverAcIDs(ctx, takeoverAcIDs)
}
func (s *FlightService) GetByDutyRosterID(ctx context.Context, dutyRosterID []byte) (*flight.Flight, error) {
return s.repo.GetByDutyRosterID(ctx, dutyRosterID)
}
func (s *FlightService) ListByDutyRosterIDs(ctx context.Context, dutyRosterIDs [][]byte) ([]flight.Flight, error) {
return s.repo.ListByDutyRosterIDs(ctx, dutyRosterIDs)
}
func (s *FlightService) List(ctx context.Context, filter string, date string, sort string, limit, offset int) ([]flight.Flight, int64, error) {
return s.repo.List(ctx, filter, date, normalizeFlightSort(sort), limit, offset)
}
func normalizeFlightSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortField("flights.mission_code", "mission_code"),
sortField("flights.date", "date"),
sortField("flights.created_at", "created_at"),
sortField("flights.updated_at", "updated_at"),
); normalized != "" {
return normalized
}
return "flights.date DESC, flights.created_at DESC"
}

View File

@@ -0,0 +1,289 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"wucher/internal/domain/flight"
fmreport "wucher/internal/domain/fm_report"
"wucher/internal/shared/pkg/appctx"
"wucher/internal/shared/pkg/uuidv7"
)
const reportCodeMaxAttempts = 5
type FMReportService struct {
repo fmreport.Repository
historyBuilder fmreport.FleetHistoryBuilder
codeResolver fmreport.ReportCodeResolver
usage interface {
RefreshAll(context.Context) error
}
}
func NewFMReportService(repo fmreport.Repository) *FMReportService {
return &FMReportService{repo: repo}
}
func (s *FMReportService) WithFleetHistoryBuilder(builder fmreport.FleetHistoryBuilder) *FMReportService {
s.historyBuilder = builder
return s
}
func (s *FMReportService) WithReportCodeResolver(resolver fmreport.ReportCodeResolver) *FMReportService {
s.codeResolver = resolver
return s
}
func (s *FMReportService) WithUsageRefresher(refresher interface {
RefreshAll(context.Context) error
}) *FMReportService {
s.usage = refresher
return s
}
func (s *FMReportService) resolveHelicopterForReportCode(ctx context.Context, takeoverID []byte) ([]byte, string) {
if s.codeResolver == nil || len(takeoverID) != 16 {
return nil, ""
}
helicopterID, reportPrefix := s.codeResolver.ResolveHelicopterForReportCode(ctx, takeoverID)
reportPrefix = strings.ToUpper(strings.TrimSpace(reportPrefix))
if len(helicopterID) != 16 || reportPrefix == "" {
return nil, ""
}
return helicopterID, reportPrefix
}
func (s *FMReportService) assignReportCode(ctx context.Context, row *fmreport.Report, helicopterID []byte, reportPrefix string) {
if len(helicopterID) != 16 || reportPrefix == "" || len(row.ID) != 16 {
return
}
for attempt := 0; attempt < reportCodeMaxAttempts; attempt++ {
maxSeq, err := s.repo.MaxReportSeqByHelicopter(ctx, helicopterID)
if err != nil {
return
}
code := reportPrefix + "-" + fmt.Sprintf("%04d", maxSeq+1)
if err := s.repo.SetReportCode(ctx, row.ID, code); err != nil {
if errors.Is(err, fmreport.ErrDuplicateReportCode) {
continue
}
return
}
row.ReportCode = &code
return
}
}
func (s *FMReportService) GetFleetHistory(ctx context.Context, reportID []byte) (*fmreport.FleetHistory, error) {
if len(reportID) != 16 {
return nil, fmt.Errorf("report id must be a valid UUID")
}
return s.repo.GetFleetHistoryByReportID(ctx, reportID)
}
func (s *FMReportService) UpsertForFlight(ctx context.Context, flightRow *flight.Flight, flightInspectionID []byte) (*fmreport.Report, error) {
if flightRow == nil || len(flightRow.ID) != 16 {
return nil, fmt.Errorf("flight is required")
}
if len(flightRow.TakeoverAcID) != 16 {
return nil, fmt.Errorf("takeover id is required")
}
if len(flightInspectionID) != 16 {
return nil, fmt.Errorf("flight inspection id is required")
}
row := &fmreport.Report{
FlightID: flightRow.ID,
TakeoverID: flightRow.TakeoverAcID,
FlightInspectionID: flightInspectionID,
}
if userID := appctx.GetUserID(ctx); len(userID) == 16 {
row.CreatedBy = userID
row.UpdatedBy = userID
}
isNew := true
if existing, err := s.repo.GetByFlightID(ctx, flightRow.ID); err == nil && existing != nil && len(existing.ID) == 16 {
isNew = false
if existing.CompletedAt != nil {
return existing, nil
}
row.ID = existing.ID
row.CreatedAt = existing.CreatedAt
row.CreatedBy = existing.CreatedBy
row.HelicopterID = existing.HelicopterID
row.ReportCode = existing.ReportCode
row.Engine1GpcN1 = existing.Engine1GpcN1
row.Engine1PtcN2 = existing.Engine1PtcN2
row.Engine2GpcN1 = existing.Engine2GpcN1
row.Engine2PtcN2 = existing.Engine2PtcN2
}
var helicopterID []byte
var reportPrefix string
if isNew {
helicopterID, reportPrefix = s.resolveHelicopterForReportCode(ctx, flightRow.TakeoverAcID)
if len(helicopterID) == 16 {
row.HelicopterID = helicopterID
}
}
if err := s.repo.Upsert(ctx, row); err != nil {
return nil, fmt.Errorf("failed to upsert fm report: %w", err)
}
if isNew {
s.assignReportCode(ctx, row, helicopterID, reportPrefix)
}
// The fleet-status snapshot is frozen at COMPLETION (see Complete), not here — while the
// report is open its fleet_status is shown live so fleet updates reflect.
return row, nil
}
// snapshotFleetStatus freezes the current fleet status for a report (idempotent: no-op if a
// snapshot already exists for the report).
func (s *FMReportService) snapshotFleetStatus(ctx context.Context, row *fmreport.Report) {
if s.historyBuilder == nil || len(row.ID) != 16 {
return
}
if existing, _ := s.repo.GetFleetHistoryByReportID(ctx, row.ID); existing != nil {
return
}
if history := s.historyBuilder.BuildFleetHistory(ctx, row.TakeoverID); history != nil {
history.FMReportID = row.ID
_ = s.repo.CreateFleetHistory(ctx, history)
}
}
func (s *FMReportService) UpdateForFlight(ctx context.Context, flightID []byte, engine1GpcN1, engine1PtcN2, engine2GpcN1, engine2PtcN2 *string) (*fmreport.Report, error) {
if len(flightID) != 16 {
return nil, fmt.Errorf("flight id must be a valid UUID")
}
row, err := s.repo.GetByFlightID(ctx, flightID)
if err != nil {
return nil, err
}
if row == nil || len(row.ID) != 16 {
return nil, fmreport.ErrNotFound
}
if row.CompletedAt != nil {
return nil, fmreport.ErrCompleted
}
if engine1GpcN1 != nil {
v := strings.TrimSpace(*engine1GpcN1)
row.Engine1GpcN1 = &v
}
if engine1PtcN2 != nil {
v := strings.TrimSpace(*engine1PtcN2)
row.Engine1PtcN2 = &v
}
if engine2GpcN1 != nil {
v := strings.TrimSpace(*engine2GpcN1)
row.Engine2GpcN1 = &v
}
if engine2PtcN2 != nil {
v := strings.TrimSpace(*engine2PtcN2)
row.Engine2PtcN2 = &v
}
if userID := appctx.GetUserID(ctx); len(userID) == 16 {
row.UpdatedBy = userID
}
if err := s.repo.Upsert(ctx, row); err != nil {
return nil, fmt.Errorf("failed to update fm report: %w", err)
}
return row, nil
}
func (s *FMReportService) Complete(ctx context.Context, flightID []byte) (*fmreport.Report, error) {
if len(flightID) != 16 {
return nil, fmt.Errorf("flight id must be a valid UUID")
}
row, err := s.repo.GetByFlightID(ctx, flightID)
if err != nil {
return nil, err
}
if row == nil || len(row.ID) != 16 {
return nil, fmreport.ErrNotFound
}
if row.CompletedAt != nil {
if s.usage != nil {
if err := s.usage.RefreshAll(ctx); err != nil {
return nil, err
}
}
return row, nil
}
now := time.Now().UTC()
row.CompletedAt = &now
if userID := appctx.GetUserID(ctx); len(userID) == 16 {
row.CompletedBy = userID
row.UpdatedBy = userID
}
if err := s.repo.Upsert(ctx, row); err != nil {
return nil, fmt.Errorf("failed to complete fm report: %w", err)
}
// Freeze the fleet-status snapshot at the moment of completion.
s.snapshotFleetStatus(ctx, row)
if s.usage != nil {
if err := s.usage.RefreshAll(ctx); err != nil {
return nil, err
}
}
return row, nil
}
func (s *FMReportService) GetByID(ctx context.Context, id []byte) (*fmreport.Report, error) {
if len(id) != 16 {
return nil, fmt.Errorf("id must be a valid UUID")
}
return s.repo.GetByID(ctx, id)
}
func (s *FMReportService) GetByFlightID(ctx context.Context, flightID []byte) (*fmreport.Report, error) {
if len(flightID) != 16 {
return nil, fmt.Errorf("flight id must be a valid UUID")
}
row, err := s.repo.GetByFlightID(ctx, flightID)
if err != nil {
return nil, err
}
return row, nil
}
func (s *FMReportService) List(ctx context.Context, filter fmreport.ListFilter, limit, offset int) ([]fmreport.Report, int64, error) {
return s.repo.List(ctx, filter, limit, offset)
}
func (s *FMReportService) Delete(ctx context.Context, id []byte) error {
if len(id) != 16 {
return fmt.Errorf("id must be a valid UUID")
}
row, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if row == nil || len(row.ID) != 16 {
return fmreport.ErrNotFound
}
var deletedBy []byte
if userID := appctx.GetUserID(ctx); len(userID) == 16 {
deletedBy = userID
}
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *FMReportService) AttachmentReferenceType() string { return "fm_report" }
func (s *FMReportService) AttachmentReferenceID(row *fmreport.Report) string {
if row == nil {
return ""
}
id, err := uuidv7.BytesToString(row.ID)
if err != nil {
return ""
}
return strings.TrimSpace(id)
}

View File

@@ -0,0 +1,356 @@
package service
import (
"context"
"testing"
"time"
"wucher/internal/domain/flight"
fmreport "wucher/internal/domain/fm_report"
"wucher/internal/shared/pkg/appctx"
"wucher/internal/shared/pkg/uuidv7"
)
type fmReportRepoMock struct {
upsertFn func(context.Context, *fmreport.Report) error
getByFlightFn func(context.Context, []byte) (*fmreport.Report, error)
maxReportSeqFn func(context.Context, []byte) (int, error)
setReportCodeFn func(context.Context, []byte, string) error
listFn func(context.Context, fmreport.ListFilter, int, int) ([]fmreport.Report, int64, error)
createFleetHistoryFn func(context.Context, *fmreport.FleetHistory) error
getFleetHistoryFn func(context.Context, []byte) (*fmreport.FleetHistory, error)
}
type fmReportUsageRefresherStub struct {
calls int
}
func (s *fmReportUsageRefresherStub) RefreshAll(context.Context) error {
s.calls++
return nil
}
func (m *fmReportRepoMock) MaxReportSeqByHelicopter(ctx context.Context, helicopterID []byte) (int, error) {
if m.maxReportSeqFn != nil {
return m.maxReportSeqFn(ctx, helicopterID)
}
return 0, nil
}
func (m *fmReportRepoMock) SetReportCode(ctx context.Context, id []byte, code string) error {
if m.setReportCodeFn != nil {
return m.setReportCodeFn(ctx, id, code)
}
return nil
}
func fmStrPtr(v string) *string { return &v }
func (m *fmReportRepoMock) CreateFleetHistory(ctx context.Context, row *fmreport.FleetHistory) error {
if m.createFleetHistoryFn != nil {
return m.createFleetHistoryFn(ctx, row)
}
return nil
}
func (m *fmReportRepoMock) GetFleetHistoryByReportID(ctx context.Context, reportID []byte) (*fmreport.FleetHistory, error) {
if m.getFleetHistoryFn != nil {
return m.getFleetHistoryFn(ctx, reportID)
}
return nil, nil
}
func (m *fmReportRepoMock) Upsert(ctx context.Context, row *fmreport.Report) error {
if m.upsertFn != nil {
return m.upsertFn(ctx, row)
}
return nil
}
func (m *fmReportRepoMock) GetByFlightID(ctx context.Context, flightID []byte) (*fmreport.Report, error) {
if m.getByFlightFn != nil {
return m.getByFlightFn(ctx, flightID)
}
return nil, nil
}
func (m *fmReportRepoMock) List(ctx context.Context, filter fmreport.ListFilter, limit, offset int) ([]fmreport.Report, int64, error) {
if m.listFn != nil {
return m.listFn(ctx, filter, limit, offset)
}
return nil, 0, nil
}
func TestFMReportServiceUpsertForFlightPreservesExistingMetrics(t *testing.T) {
flightID := uuidv7.MustBytes()
takeoverID := uuidv7.MustBytes()
inspectionID := uuidv7.MustBytes()
existingID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
refresher := &fmReportUsageRefresherStub{}
repo := &fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return &fmreport.Report{
ID: existingID,
FlightID: flightID,
TakeoverID: takeoverID,
FlightInspectionID: inspectionID,
Engine1GpcN1: fmStrPtr("old-e1-gpc"),
Engine1PtcN2: fmStrPtr("old-e1-ptc"),
Engine2GpcN1: fmStrPtr("old-e2-gpc"),
Engine2PtcN2: fmStrPtr("old-e2-ptc"),
CreatedAt: time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC),
CreatedBy: userID,
}, nil
},
upsertFn: func(_ context.Context, row *fmreport.Report) error {
if row.Engine1GpcN1 == nil || row.Engine1PtcN2 == nil || row.Engine2GpcN1 == nil || row.Engine2PtcN2 == nil {
t.Fatalf("expected metrics preserved, got %+v", row)
}
if *row.Engine1GpcN1 != "old-e1-gpc" || *row.Engine1PtcN2 != "old-e1-ptc" || *row.Engine2GpcN1 != "old-e2-gpc" || *row.Engine2PtcN2 != "old-e2-ptc" {
t.Fatalf("expected metrics preserved, got %+v", row)
}
return nil
},
}
svc := NewFMReportService(repo).WithUsageRefresher(refresher)
ctx := appctx.WithUserID(context.Background(), userID)
got, err := svc.UpsertForFlight(ctx, &flight.Flight{ID: flightID, TakeoverAcID: takeoverID}, inspectionID)
if err != nil {
t.Fatalf("UpsertForFlight: %v", err)
}
if got == nil || got.Engine1GpcN1 == nil || got.Engine1PtcN2 == nil || got.Engine2GpcN1 == nil || got.Engine2PtcN2 == nil {
t.Fatalf("unexpected report: %+v", got)
}
if *got.Engine1GpcN1 != "old-e1-gpc" || *got.Engine1PtcN2 != "old-e1-ptc" || *got.Engine2GpcN1 != "old-e2-gpc" || *got.Engine2PtcN2 != "old-e2-ptc" {
t.Fatalf("unexpected report: %+v", got)
}
if refresher.calls != 0 {
t.Fatalf("expected no usage refresh on upsert, got %d", refresher.calls)
}
}
func TestFMReportServiceUpdateForFlight(t *testing.T) {
flightID := uuidv7.MustBytes()
reportID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
refresher := &fmReportUsageRefresherStub{}
repo := &fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return &fmreport.Report{
ID: reportID,
FlightID: flightID,
TakeoverID: uuidv7.MustBytes(),
FlightInspectionID: uuidv7.MustBytes(),
Engine1GpcN1: fmStrPtr("old-e1-gpc"),
Engine1PtcN2: fmStrPtr("old-e1-ptc"),
Engine2GpcN1: fmStrPtr("old-e2-gpc"),
Engine2PtcN2: fmStrPtr("old-e2-ptc"),
}, nil
},
upsertFn: func(ctx context.Context, row *fmreport.Report) error {
if row.Engine1GpcN1 == nil || row.Engine1PtcN2 == nil || row.Engine2GpcN1 == nil || row.Engine2PtcN2 == nil {
t.Fatalf("unexpected patched values: %+v", row)
}
if *row.Engine1GpcN1 != "new-e1-gpc" || *row.Engine1PtcN2 != "new-e1-ptc" || *row.Engine2GpcN1 != "new-e2-gpc" || *row.Engine2PtcN2 != "new-e2-ptc" {
t.Fatalf("unexpected patched values: %+v", row)
}
if got := appctx.GetUserID(ctx); string(got) != string(userID) {
t.Fatalf("expected updated by user to be preserved in context")
}
return nil
},
}
svc := NewFMReportService(repo).WithUsageRefresher(refresher)
ctx := appctx.WithUserID(context.Background(), userID)
got, err := svc.UpdateForFlight(ctx, flightID, fmStrPtr("new-e1-gpc"), fmStrPtr("new-e1-ptc"), fmStrPtr("new-e2-gpc"), fmStrPtr("new-e2-ptc"))
if err != nil {
t.Fatalf("UpdateForFlight: %v", err)
}
if got == nil || got.Engine1GpcN1 == nil || got.Engine1PtcN2 == nil || got.Engine2GpcN1 == nil || got.Engine2PtcN2 == nil {
t.Fatalf("unexpected report: %+v", got)
}
if *got.Engine1GpcN1 != "new-e1-gpc" || *got.Engine1PtcN2 != "new-e1-ptc" || *got.Engine2GpcN1 != "new-e2-gpc" || *got.Engine2PtcN2 != "new-e2-ptc" {
t.Fatalf("unexpected report: %+v", got)
}
if refresher.calls != 0 {
t.Fatalf("expected no usage refresh on update, got %d", refresher.calls)
}
}
type fmReportCodeResolverMock struct {
helicopterID []byte
helicopterType string
}
func (m fmReportCodeResolverMock) ResolveHelicopterForReportCode(context.Context, []byte) ([]byte, string) {
return m.helicopterID, m.helicopterType
}
func TestFMReportServiceUpsertForFlightAssignsReportCode(t *testing.T) {
flightID := uuidv7.MustBytes()
takeoverID := uuidv7.MustBytes()
inspectionID := uuidv7.MustBytes()
helicopterID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
var saved *fmreport.Report
var setCode string
repo := &fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return nil, nil
},
maxReportSeqFn: func(_ context.Context, gotHeli []byte) (int, error) {
if string(gotHeli) != string(helicopterID) {
t.Fatalf("expected counter scoped to helicopter, got %x", gotHeli)
}
return 4, nil
},
upsertFn: func(_ context.Context, row *fmreport.Report) error {
saved = row
if string(row.HelicopterID) != string(helicopterID) {
t.Fatalf("expected helicopter id persisted, got %x", row.HelicopterID)
}
if row.ReportCode != nil {
t.Fatalf("expected report code unset on insert, got %q", *row.ReportCode)
}
if len(row.ID) != 16 {
row.ID = uuidv7.MustBytes()
}
return nil
},
setReportCodeFn: func(_ context.Context, _ []byte, code string) error {
setCode = code
return nil
},
}
svc := NewFMReportService(repo).WithReportCodeResolver(fmReportCodeResolverMock{
helicopterID: helicopterID,
helicopterType: "SN0901",
})
ctx := appctx.WithUserID(context.Background(), userID)
got, err := svc.UpsertForFlight(ctx, &flight.Flight{ID: flightID, TakeoverAcID: takeoverID}, inspectionID)
if err != nil {
t.Fatalf("UpsertForFlight: %v", err)
}
if saved == nil {
t.Fatalf("expected report upserted")
}
if setCode != "SN0901-0005" {
t.Fatalf("expected report code SN0901-0005 persisted, got %q", setCode)
}
if got == nil || got.ReportCode == nil || *got.ReportCode != "SN0901-0005" {
t.Fatalf("unexpected returned report: %+v", got)
}
}
func TestFMReportServiceUpsertForFlightRetriesOnDuplicateReportCode(t *testing.T) {
flightID := uuidv7.MustBytes()
takeoverID := uuidv7.MustBytes()
inspectionID := uuidv7.MustBytes()
helicopterID := uuidv7.MustBytes()
maxCalls := 0
setCalls := 0
repo := &fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return nil, nil
},
maxReportSeqFn: func(context.Context, []byte) (int, error) {
// First read sees 4; after the concurrent winner committed SN0901-0005,
// the retry sees 5.
maxCalls++
if maxCalls == 1 {
return 4, nil
}
return 5, nil
},
setReportCodeFn: func(_ context.Context, _ []byte, code string) error {
setCalls++
if code == "SN0901-0005" {
return fmreport.ErrDuplicateReportCode
}
return nil
},
upsertFn: func(_ context.Context, row *fmreport.Report) error {
if len(row.ID) != 16 {
row.ID = uuidv7.MustBytes()
}
return nil
},
}
svc := NewFMReportService(repo).WithReportCodeResolver(fmReportCodeResolverMock{
helicopterID: helicopterID,
helicopterType: "SN0901",
})
got, err := svc.UpsertForFlight(context.Background(), &flight.Flight{ID: flightID, TakeoverAcID: takeoverID}, inspectionID)
if err != nil {
t.Fatalf("UpsertForFlight: %v", err)
}
if setCalls != 2 {
t.Fatalf("expected one retry after duplicate, got %d SetReportCode calls", setCalls)
}
if got == nil || got.ReportCode == nil || *got.ReportCode != "SN0901-0006" {
t.Fatalf("expected report code SN0901-0006 after retry, got %+v", got)
}
}
func TestFMReportServiceUpdateForFlightNotFound(t *testing.T) {
svc := NewFMReportService(&fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return nil, nil
},
})
if _, err := svc.UpdateForFlight(context.Background(), uuidv7.MustBytes(), fmStrPtr("a"), fmStrPtr("b"), fmStrPtr("c"), fmStrPtr("d")); err == nil {
t.Fatal("expected error")
}
}
func TestFMReportServiceCompleteSetsCompletionFieldsAndRefreshesUsage(t *testing.T) {
flightID := uuidv7.MustBytes()
reportID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
refresher := &fmReportUsageRefresherStub{}
repo := &fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return &fmreport.Report{
ID: reportID,
FlightID: flightID,
TakeoverID: uuidv7.MustBytes(),
FlightInspectionID: uuidv7.MustBytes(),
}, nil
},
upsertFn: func(ctx context.Context, row *fmreport.Report) error {
if row.CompletedAt == nil {
t.Fatal("expected completed_at to be set")
}
if len(row.CompletedBy) != 16 {
t.Fatal("expected completed_by to be set")
}
if got := appctx.GetUserID(ctx); string(got) != string(userID) {
t.Fatal("expected user id propagated in context")
}
return nil
},
}
svc := NewFMReportService(repo).WithUsageRefresher(refresher)
ctx := appctx.WithUserID(context.Background(), userID)
got, err := svc.Complete(ctx, flightID)
if err != nil {
t.Fatalf("Complete: %v", err)
}
if got == nil || got.CompletedAt == nil || len(got.CompletedBy) != 16 {
t.Fatalf("unexpected completed report: %+v", got)
}
if refresher.calls != 1 {
t.Fatalf("expected one usage refresh, got %d", refresher.calls)
}
}

View File

@@ -0,0 +1,52 @@
package service
import (
"context"
"testing"
fmreport "wucher/internal/domain/fm_report"
"wucher/internal/shared/pkg/uuidv7"
)
type fmSnapshotBuilderStub struct {
built *fmreport.FleetHistory
calls int
}
func (b *fmSnapshotBuilderStub) BuildFleetHistory(context.Context, []byte) *fmreport.FleetHistory {
b.calls++
return b.built
}
// TestFMReportCompleteFreezesFleetSnapshot verifies the fleet-status snapshot is written at
// COMPLETION (so the FM report shows live fleet status while open, frozen once completed).
func TestFMReportCompleteFreezesFleetSnapshot(t *testing.T) {
flightID := uuidv7.MustBytes()
reportID := uuidv7.MustBytes()
takeoverID := uuidv7.MustBytes()
var created *fmreport.FleetHistory
repo := &fmReportRepoMock{
getByFlightFn: func(context.Context, []byte) (*fmreport.Report, error) {
return &fmreport.Report{ID: reportID, FlightID: flightID, TakeoverID: takeoverID, FlightInspectionID: uuidv7.MustBytes()}, nil
},
upsertFn: func(context.Context, *fmreport.Report) error { return nil },
getFleetHistoryFn: func(context.Context, []byte) (*fmreport.FleetHistory, error) { return nil, nil },
createFleetHistoryFn: func(_ context.Context, row *fmreport.FleetHistory) error {
created = row
return nil
},
}
builder := &fmSnapshotBuilderStub{built: &fmreport.FleetHistory{}}
svc := NewFMReportService(repo).WithFleetHistoryBuilder(builder)
if _, err := svc.Complete(context.Background(), flightID); err != nil {
t.Fatalf("complete: %v", err)
}
if builder.calls != 1 {
t.Fatalf("expected snapshot built once at completion, got %d", builder.calls)
}
if created == nil || string(created.FMReportID) != string(reportID) {
t.Fatalf("expected snapshot created for the report at completion, got %+v", created)
}
}

View File

@@ -0,0 +1,51 @@
package service
import (
"context"
"wucher/internal/domain/forces_present"
"wucher/internal/shared/pkg/sortkey"
)
type ForcesPresentService struct {
repo forces_present.Repository
}
func NewForcesPresentService(repo forces_present.Repository) *ForcesPresentService {
return &ForcesPresentService{repo: repo}
}
func (s *ForcesPresentService) Create(ctx context.Context, row *forces_present.ForcesPresent) error {
return s.repo.Create(ctx, row)
}
func (s *ForcesPresentService) Update(ctx context.Context, row *forces_present.ForcesPresent) error {
return s.repo.Update(ctx, row)
}
func (s *ForcesPresentService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *ForcesPresentService) GetByID(ctx context.Context, id []byte) (*forces_present.ForcesPresent, error) {
return s.repo.GetByID(ctx, id)
}
func (s *ForcesPresentService) List(ctx context.Context, filter, sort string, limit, offset int) ([]forces_present.ForcesPresent, int64, error) {
return s.repo.List(ctx, filter, normalizeForcesPresentSort(sort), limit, offset)
}
func normalizeForcesPresentSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("name"),
sortField("note"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,223 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/forces_present"
)
type forcesPresentRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *forces_present.ForcesPresent
lastUpdate *forces_present.ForcesPresent
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *forces_present.ForcesPresent
listResult []forces_present.ForcesPresent
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *forcesPresentRepoMock) Create(_ context.Context, row *forces_present.ForcesPresent) error {
m.createCalled = true
m.lastCreate = row
return m.createErr
}
func (m *forcesPresentRepoMock) Update(_ context.Context, row *forces_present.ForcesPresent) error {
m.updateCalled = true
m.lastUpdate = row
return m.updateErr
}
func (m *forcesPresentRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *forcesPresentRepoMock) GetByID(_ context.Context, id []byte) (*forces_present.ForcesPresent, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *forcesPresentRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]forces_present.ForcesPresent, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewForcesPresentService(t *testing.T) {
repo := &forcesPresentRepoMock{}
svc := NewForcesPresentService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestForcesPresentServiceCreate(t *testing.T) {
repo := &forcesPresentRepoMock{}
svc := NewForcesPresentService(repo)
row := &forces_present.ForcesPresent{Name: "Team A"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestForcesPresentServiceUpdate(t *testing.T) {
repo := &forcesPresentRepoMock{}
svc := NewForcesPresentService(repo)
row := &forces_present.ForcesPresent{Name: "Team B"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestForcesPresentServiceDelete(t *testing.T) {
repo := &forcesPresentRepoMock{}
svc := NewForcesPresentService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestForcesPresentServiceGetByID(t *testing.T) {
expected := &forces_present.ForcesPresent{Name: "Team A"}
repo := &forcesPresentRepoMock{getByIDResult: expected}
svc := NewForcesPresentService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestForcesPresentServiceList(t *testing.T) {
repo := &forcesPresentRepoMock{
listResult: []forces_present.ForcesPresent{{Name: "Team A"}},
listTotal: 1,
}
svc := NewForcesPresentService(repo)
rows, total, err := svc.List(context.Background(), "find", "name", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "name ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeForcesPresentSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, name ASC",
"note": "note ASC",
"-note": "note DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeForcesPresentSort(in); got != want {
t.Fatalf("normalizeForcesPresentSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,63 @@
package service
import (
"context"
"wucher/internal/domain/health_insurance_companies"
"wucher/internal/shared/pkg/sortkey"
)
type HealthInsuranceCompaniesService struct {
repo health_insurance_companies.Repository
}
func NewHealthInsuranceCompaniesService(repo health_insurance_companies.Repository) *HealthInsuranceCompaniesService {
return &HealthInsuranceCompaniesService{repo: repo}
}
func (s *HealthInsuranceCompaniesService) Create(ctx context.Context, row *health_insurance_companies.HealthInsuranceCompany) error {
return s.repo.Create(ctx, row)
}
func (s *HealthInsuranceCompaniesService) Update(ctx context.Context, row *health_insurance_companies.HealthInsuranceCompany) error {
return s.repo.Update(ctx, row)
}
func (s *HealthInsuranceCompaniesService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *HealthInsuranceCompaniesService) GetByID(ctx context.Context, id []byte) (*health_insurance_companies.HealthInsuranceCompany, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HealthInsuranceCompaniesService) List(ctx context.Context, filter, sort string, limit, offset int) ([]health_insurance_companies.HealthInsuranceCompany, int64, error) {
return s.repo.List(ctx, filter, normalizeHealthInsuranceCompaniesSort(sort), limit, offset)
}
func (s *HealthInsuranceCompaniesService) ResolveStateDetails(ctx context.Context, stateNames []string) (map[string]health_insurance_companies.StateDetail, error) {
return s.repo.ResolveStateDetails(ctx, stateNames)
}
func (s *HealthInsuranceCompaniesService) ResolveLandDetails(ctx context.Context, landIDs []string) (map[string]health_insurance_companies.LandDetail, error) {
return s.repo.ResolveLandDetails(ctx, landIDs)
}
func normalizeHealthInsuranceCompaniesSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("name"),
sortField("state"),
sortField("address"),
sortField("mobile_number"),
sortField("email"),
sortField("note"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,275 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/health_insurance_companies"
)
type healthInsuranceCompaniesRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *health_insurance_companies.HealthInsuranceCompany
lastUpdate *health_insurance_companies.HealthInsuranceCompany
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *health_insurance_companies.HealthInsuranceCompany
listResult []health_insurance_companies.HealthInsuranceCompany
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
resolveErr error
resolveResult map[string]health_insurance_companies.StateDetail
resolveLandResult map[string]health_insurance_companies.LandDetail
}
func (m *healthInsuranceCompaniesRepoMock) Create(_ context.Context, o *health_insurance_companies.HealthInsuranceCompany) error {
m.createCalled = true
m.lastCreate = o
return m.createErr
}
func (m *healthInsuranceCompaniesRepoMock) Update(_ context.Context, o *health_insurance_companies.HealthInsuranceCompany) error {
m.updateCalled = true
m.lastUpdate = o
return m.updateErr
}
func (m *healthInsuranceCompaniesRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *healthInsuranceCompaniesRepoMock) GetByID(_ context.Context, id []byte) (*health_insurance_companies.HealthInsuranceCompany, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *healthInsuranceCompaniesRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]health_insurance_companies.HealthInsuranceCompany, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func (m *healthInsuranceCompaniesRepoMock) ResolveStateDetails(_ context.Context, _ []string) (map[string]health_insurance_companies.StateDetail, error) {
return m.resolveResult, m.resolveErr
}
func (m *healthInsuranceCompaniesRepoMock) ResolveLandDetails(_ context.Context, _ []string) (map[string]health_insurance_companies.LandDetail, error) {
return m.resolveLandResult, m.resolveErr
}
func TestNewHealthInsuranceCompaniesService(t *testing.T) {
repo := &healthInsuranceCompaniesRepoMock{}
svc := NewHealthInsuranceCompaniesService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestHealthInsuranceCompaniesServiceCreate(t *testing.T) {
repo := &healthInsuranceCompaniesRepoMock{}
svc := NewHealthInsuranceCompaniesService(repo)
row := &health_insurance_companies.HealthInsuranceCompany{Name: "Name"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHealthInsuranceCompaniesServiceUpdate(t *testing.T) {
repo := &healthInsuranceCompaniesRepoMock{}
svc := NewHealthInsuranceCompaniesService(repo)
row := &health_insurance_companies.HealthInsuranceCompany{Name: "Updated"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHealthInsuranceCompaniesServiceDelete(t *testing.T) {
repo := &healthInsuranceCompaniesRepoMock{}
svc := NewHealthInsuranceCompaniesService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHealthInsuranceCompaniesServiceGetByID(t *testing.T) {
expected := &health_insurance_companies.HealthInsuranceCompany{Name: "A"}
repo := &healthInsuranceCompaniesRepoMock{getByIDResult: expected}
svc := NewHealthInsuranceCompaniesService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHealthInsuranceCompaniesServiceList(t *testing.T) {
repo := &healthInsuranceCompaniesRepoMock{
listResult: []health_insurance_companies.HealthInsuranceCompany{{Name: "A"}},
listTotal: 1,
}
svc := NewHealthInsuranceCompaniesService(repo)
rows, total, err := svc.List(context.Background(), "find", "name", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "name ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeHealthInsuranceCompaniesSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, name ASC",
"state": "state ASC",
"-state": "state DESC",
"address": "address ASC",
"-address": "address DESC",
"mobile_number": "mobile_number ASC",
"-mobile_number": "mobile_number DESC",
"email": "email ASC",
"-email": "email DESC",
"note": "note ASC",
"-note": "note DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeHealthInsuranceCompaniesSort(in); got != want {
t.Fatalf("normalizeHealthInsuranceCompaniesSort(%q) = %q, want %q", in, got, want)
}
}
}
func TestHealthInsuranceCompaniesServiceResolveStateDetails(t *testing.T) {
want := map[string]health_insurance_companies.StateDetail{
"berlin": {FederalStateID: "id1", FederalState: "Berlin", LandName: "Deutschland", LandISOCode: "DE"},
}
repo := &healthInsuranceCompaniesRepoMock{resolveResult: want}
svc := NewHealthInsuranceCompaniesService(repo)
got, err := svc.ResolveStateDetails(context.Background(), []string{"Berlin"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["berlin"].FederalStateID != "id1" {
t.Fatalf("unexpected resolve result")
}
}
func TestHealthInsuranceCompaniesServiceResolveLandDetails(t *testing.T) {
want := map[string]health_insurance_companies.LandDetail{
"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d": {LandID: "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d", LandName: "Deutschland", LandISOCode: "DE"},
}
repo := &healthInsuranceCompaniesRepoMock{resolveLandResult: want}
svc := NewHealthInsuranceCompaniesService(repo)
got, err := svc.ResolveLandDetails(context.Background(), []string{"018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"].LandName != "Deutschland" {
t.Fatalf("unexpected land resolve result")
}
}

View File

@@ -0,0 +1,3 @@
package service
// Placeholder for future heavy/background operations.

View File

@@ -0,0 +1,561 @@
package service
import (
"context"
"errors"
"path/filepath"
"strings"
"time"
filemanager "wucher/internal/domain/file_manager"
helicopterfile "wucher/internal/domain/helicopter_file"
helicopterfilerealtime "wucher/internal/realtime/helicopterfile"
"wucher/internal/shared/pkg/attachmentresolver"
"wucher/internal/shared/pkg/uuidv7"
)
type HelicopterFileService struct {
repo helicopterfile.Repository
fileManager filemanager.Service
events helicopterFileEventPublisher
}
func NewHelicopterFileService(repo helicopterfile.Repository) *HelicopterFileService {
return &HelicopterFileService{repo: repo}
}
type helicopterFileEventPublisher interface {
PublishToUsers(userUUIDs []string, event helicopterfilerealtime.Event) int
}
func (s *HelicopterFileService) WithFileManagerService(fileManagerSvc filemanager.Service) *HelicopterFileService {
s.fileManager = fileManagerSvc
return s
}
func (s *HelicopterFileService) WithHelicopterFileEvents(publisher helicopterFileEventPublisher) *HelicopterFileService {
s.events = publisher
return s
}
func (s *HelicopterFileService) Create(ctx context.Context, row *helicopterfile.HelicopterFile) error {
if row != nil && row.Position <= 0 {
next, err := s.nextPositionByHelicopterSection(ctx, row.HelicopterID, row.Section)
if err != nil {
return err
}
row.Position = next
}
return s.repo.Create(ctx, row)
}
func (s *HelicopterFileService) Update(ctx context.Context, row *helicopterfile.HelicopterFile) error {
return s.repo.Update(ctx, row)
}
func (s *HelicopterFileService) Delete(ctx context.Context, id []byte) error {
return s.repo.Delete(ctx, id)
}
func (s *HelicopterFileService) GetByID(ctx context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HelicopterFileService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]helicopterfile.HelicopterFile, int64, error) {
return s.repo.List(ctx, filter, normalizeHelicopterFileSort(sort), limit, offset)
}
func (s *HelicopterFileService) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]helicopterfile.HelicopterFile, error) {
return s.repo.ListByHelicopter(ctx, helicopterID)
}
func (s *HelicopterFileService) ListByHelicopterAndSection(ctx context.Context, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
return s.repo.ListByHelicopterAndSection(ctx, helicopterID, section)
}
func (s *HelicopterFileService) HelicopterExists(ctx context.Context, helicopterID []byte) (bool, error) {
return s.repo.HelicopterExists(ctx, helicopterID)
}
func (s *HelicopterFileService) AttachmentExists(ctx context.Context, attachmentID []byte) (bool, error) {
return s.repo.AttachmentExists(ctx, attachmentID)
}
func (s *HelicopterFileService) RequestUploadIntentsBulk(ctx context.Context, inputs []helicopterfile.UploadIntentBulkInput, actorID []byte) []helicopterfile.UploadIntentBulkItemResult {
items := make([]helicopterfile.UploadIntentBulkItemResult, len(inputs))
if s.fileManager == nil {
for i := range items {
items[i] = helicopterfile.UploadIntentBulkItemResult{
Index: i,
Success: false,
Error: &helicopterfile.BulkItemError{
Status: 400,
Title: "Upload intent failed",
Detail: "file manager service is not configured",
},
}
}
return items
}
for i := range inputs {
item := inputs[i]
typ := strings.TrimSpace(item.Type)
if typ != "helicopter_file_upload" {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "type must be helicopter_file_upload")
continue
}
name := strings.TrimSpace(item.Name)
if name == "" {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "name is required")
continue
}
contentType := strings.TrimSpace(item.ContentType)
if contentType == "" {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "content_type is required")
continue
}
if item.SizeBytes < 0 {
items[i] = uploadIntentBulkError(i, 422, "Validation error", "size_bytes must be greater than or equal to 0")
continue
}
grant, grantErr := s.fileManager.RequestFileUploadIntent(ctx, filemanager.RequestFileUploadIntentParams{
Name: name,
ContentType: contentType,
SizeBytes: item.SizeBytes,
ActorID: actorID,
})
if grantErr != nil {
items[i] = uploadIntentBulkError(i, 400, "Upload intent failed", grantErr.Error())
continue
}
items[i] = helicopterfile.UploadIntentBulkItemResult{
Index: i,
Success: true,
Grant: grant,
}
}
return items
}
func (s *HelicopterFileService) CreateFromUploadIntentsBulk(ctx context.Context, inputs []helicopterfile.CreateBulkInput, actorID []byte) []helicopterfile.CreateBulkItemResult {
items := make([]helicopterfile.CreateBulkItemResult, len(inputs))
if s.fileManager == nil {
for i := range items {
items[i] = createBulkError(i, 400, "Create failed", "file manager service is not configured")
}
return items
}
for i := range inputs {
item := inputs[i]
typ := strings.TrimSpace(item.Type)
if typ != "helicopter_file_create" && typ != "helicopter_file" {
items[i] = createBulkError(i, 422, "Validation error", "type must be helicopter_file_create or helicopter_file")
continue
}
section := strings.TrimSpace(item.Section)
if !isValidHelicopterFileSection(section) {
items[i] = createBulkError(i, 422, "Validation error", "section is invalid")
continue
}
helicopterID, err := uuidv7.ParseString(strings.TrimSpace(item.HelicopterID))
if err != nil {
items[i] = createBulkError(i, 422, "Validation error", "helicopter_id is invalid UUID")
continue
}
exists, err := s.repo.HelicopterExists(ctx, helicopterID)
if err != nil {
items[i] = createBulkError(i, 400, "Create failed", err.Error())
continue
}
if !exists {
items[i] = createBulkError(i, 422, "Validation error", "helicopter_id not found")
continue
}
uploadIntentID, parseErr := uuidv7.ParseString(strings.TrimSpace(item.UploadIntentUUID))
if parseErr != nil {
items[i] = createBulkError(i, 422, "Validation error", "upload_intent_uuid is invalid UUID")
continue
}
fileName := strings.TrimSpace(item.FileName)
if fileName == "" {
items[i] = createBulkError(i, 422, "Validation error", "file_name is required")
continue
}
var folderID []byte
folderProvided := false
if item.FolderUUID != nil {
folderProvided = true
folderUUID := strings.TrimSpace(*item.FolderUUID)
if folderUUID != "" {
parsedFolderID, folderErr := uuidv7.ParseString(folderUUID)
if folderErr != nil {
items[i] = createBulkError(i, 422, "Validation error", "folder_uuid is invalid UUID")
continue
}
folderID = parsedFolderID
}
}
createdFile, createErr := s.fileManager.CreateFileNodeFromUploadIntent(ctx, filemanager.CreateFileFromUploadIntentParams{
UploadIntentID: uploadIntentID,
FolderID: folderID,
FolderProvided: folderProvided,
Name: fileName,
ActorID: actorID,
IsTemplate: true,
})
if createErr != nil {
if errors.Is(createErr, filemanager.ErrDuplicateName) {
targetFolderID := folderID
if !folderProvided || len(targetFolderID) == 0 {
root, rootErr := s.fileManager.GetOrCreateSystemRootFolder(ctx)
if rootErr != nil || root == nil {
detail := "unable to resolve target folder"
if rootErr != nil {
detail = rootErr.Error()
}
items[i] = createBulkError(i, 400, "Create failed", detail)
continue
}
targetFolderID = root.ID
}
nameNormalized := strings.ToLower(filepath.Base(strings.TrimSpace(fileName)))
if nameNormalized == "" {
items[i] = createBulkError(i, 422, "Validation error", "file_name is invalid")
continue
}
existingFile, existingErr := s.fileManager.GetFileByFolderAndName(ctx, targetFolderID, nameNormalized)
if existingErr != nil || existingFile == nil {
detail := "duplicate file exists but cannot be resolved"
if existingErr != nil {
detail = existingErr.Error()
}
items[i] = createBulkError(i, 400, "Create failed", detail)
continue
}
createdFile = existingFile
} else {
items[i] = createBulkError(i, 400, "Create failed", createErr.Error())
continue
}
}
if createdFile == nil {
items[i] = createBulkError(i, 400, "Create failed", "unable to create file from upload intent")
continue
}
position := 0
if item.Position != nil {
position = *item.Position
}
isMandatory := false
if item.IsMandatory != nil {
isMandatory = *item.IsMandatory
}
row := &helicopterfile.HelicopterFile{
Section: section,
Position: position,
IsMandatory: isMandatory,
HelicopterID: helicopterID,
SourceFileID: append([]byte(nil), createdFile.ID...),
CreatedBy: actorID,
UpdatedBy: actorID,
}
if row.Position <= 0 {
next, nextErr := s.nextPositionByHelicopterSection(ctx, row.HelicopterID, row.Section)
if nextErr != nil {
items[i] = createBulkError(i, 400, "Create failed", nextErr.Error())
continue
}
row.Position = next
}
if err := s.repo.Create(ctx, row); err != nil {
items[i] = createBulkError(i, 400, "Create failed", err.Error())
continue
}
createdRow, getErr := s.repo.GetByID(ctx, row.ID)
if getErr == nil && createdRow != nil {
items[i] = helicopterfile.CreateBulkItemResult{
Index: i,
Success: true,
Row: createdRow,
}
continue
}
items[i] = helicopterfile.CreateBulkItemResult{
Index: i,
Success: true,
Row: row,
}
}
return items
}
func (s *HelicopterFileService) FinalizePendingAttachments(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil {
return 0, errors.New("helicopter file service is not configured")
}
if s.fileManager == nil {
return 0, nil
}
rows, err := s.repo.ListPendingAttachments(ctx, limit)
if err != nil {
return 0, err
}
return s.finalizePendingAttachmentRows(ctx, rows), nil
}
func (s *HelicopterFileService) FinalizePendingAttachmentsBySourceFileUUID(ctx context.Context, sourceFileUUID string, limit int) (int, error) {
if s == nil || s.repo == nil {
return 0, errors.New("helicopter file service is not configured")
}
if s.fileManager == nil {
return 0, nil
}
sourceFileUUID = strings.TrimSpace(sourceFileUUID)
if sourceFileUUID == "" {
return 0, nil
}
sourceFileID, err := uuidv7.ParseString(sourceFileUUID)
if err != nil {
return 0, err
}
rows, err := s.repo.ListPendingAttachmentsBySourceFileID(ctx, sourceFileID, limit)
if err != nil {
return 0, err
}
return s.finalizePendingAttachmentRows(ctx, rows), nil
}
func (s *HelicopterFileService) finalizePendingAttachmentRows(ctx context.Context, rows []helicopterfile.HelicopterFile) int {
finalized := 0
for i := range rows {
row := &rows[i]
if len(row.ID) != 16 || len(row.SourceFileID) != 16 || len(row.FileAttachmentID) == 16 {
continue
}
attachmentID, ok, err := s.finalizePendingAttachment(ctx, row)
if err != nil || !ok || len(attachmentID) != 16 {
continue
}
finalized++
}
return finalized
}
func (s *HelicopterFileService) finalizePendingAttachment(ctx context.Context, row *helicopterfile.HelicopterFile) ([]byte, bool, error) {
if s == nil || row == nil || s.fileManager == nil {
return nil, false, nil
}
if len(row.SourceFileID) != 16 || len(row.ID) != 16 || len(row.HelicopterID) != 16 {
return nil, false, nil
}
sourceFileUUID := uuidBytesToStringOrEmpty(row.SourceFileID)
if sourceFileUUID == "" {
return nil, false, nil
}
helicopterUUID := uuidBytesToStringOrEmpty(row.HelicopterID)
if helicopterUUID == "" {
return nil, false, nil
}
category := "helicopter_file"
attachmentID, err := attachmentresolver.Resolve(ctx, attachmentresolver.Params{
FileManager: s.fileManager,
RawFileID: &sourceFileUUID,
AttachmentField: "file_attachment_id",
FileField: "source_file_id",
RequireValue: true,
RefType: "helicopter",
RefID: helicopterUUID,
Category: &category,
IsPrimary: false,
ActorID: append([]byte(nil), row.UpdatedBy...),
})
if err != nil {
var resolveErr *attachmentresolver.ResolveError
if errors.As(err, &resolveErr) && resolveErr.Code == attachmentresolver.ErrorFileNotReadyForAttachment {
return nil, false, nil
}
return nil, false, err
}
if len(attachmentID) != 16 {
return nil, false, nil
}
row.FileAttachmentID = append([]byte(nil), attachmentID...)
row.SourceFileID = nil
row.UpdatedAt = time.Now().UTC()
if len(row.UpdatedBy) == 0 {
row.UpdatedBy = append([]byte(nil), row.CreatedBy...)
}
if err := s.repo.Update(ctx, row); err != nil {
return nil, false, err
}
s.publishHelicopterFileState(row, attachmentID)
if err := s.markAttachmentFileActive(ctx, attachmentID, row.UpdatedBy); err != nil {
return attachmentID, true, err
}
return attachmentID, true, nil
}
func (s *HelicopterFileService) publishHelicopterFileState(row *helicopterfile.HelicopterFile, fileID []byte) {
if s == nil || s.events == nil || row == nil || len(row.ID) != 16 || len(row.HelicopterID) != 16 {
return
}
actorID := row.UpdatedBy
if len(actorID) != 16 {
actorID = row.CreatedBy
}
if len(actorID) != 16 {
return
}
actorUUID := uuidBytesToStringOrEmpty(actorID)
helicopterUUID := uuidBytesToStringOrEmpty(row.HelicopterID)
helicopterFileUUID := uuidBytesToStringOrEmpty(row.ID)
attachmentUUID := uuidBytesToStringOrEmpty(fileID)
if attachmentUUID == "" {
attachmentUUID = uuidBytesToStringOrEmpty(row.FileAttachmentID)
}
if actorUUID == "" || helicopterUUID == "" || helicopterFileUUID == "" || attachmentUUID == "" {
return
}
updatedAt := row.UpdatedAt.UTC()
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
sourceFile := row.SourceFile
name := ""
mimeType := ""
if sourceFile != nil {
name = strings.TrimSpace(sourceFile.Name)
mimeType = strings.TrimSpace(sourceFile.MimeType)
}
s.events.PublishToUsers([]string{actorUUID}, helicopterfilerealtime.Event{
EventType: helicopterfilerealtime.DefaultEventType,
HelicopterID: helicopterUUID,
HelicopterFileID: helicopterFileUUID,
FileID: attachmentUUID,
Section: strings.TrimSpace(row.Section),
Name: name,
MimeType: mimeType,
AttachmentPending: len(row.FileAttachmentID) != 16 && len(row.SourceFileID) == 16,
Position: row.Position,
IsMandatory: row.IsMandatory,
UpdatedAt: updatedAt.Format(time.RFC3339),
})
}
func (s *HelicopterFileService) markAttachmentFileActive(ctx context.Context, attachmentID []byte, actorID []byte) error {
if s == nil || s.fileManager == nil || len(attachmentID) != 16 {
return nil
}
lifecycle, ok := s.fileManager.(filemanager.LifecycleManager)
if !ok {
return nil
}
attachment, err := s.fileManager.GetAttachmentByID(ctx, attachmentID)
if err != nil || attachment == nil || len(attachment.FileID) != 16 {
return err
}
return lifecycle.MarkFileActive(ctx, attachment.FileID, actorID)
}
func (s *HelicopterFileService) nextPositionByHelicopterSection(ctx context.Context, helicopterID []byte, section string) (int, error) {
rows, err := s.repo.ListByHelicopterAndSection(ctx, helicopterID, section)
if err != nil {
return 0, err
}
maxPos := 0
for i := range rows {
if rows[i].Position > maxPos {
maxPos = rows[i].Position
}
}
return maxPos + 1, nil
}
func uploadIntentBulkError(index int, status int, title, detail string) helicopterfile.UploadIntentBulkItemResult {
return helicopterfile.UploadIntentBulkItemResult{
Index: index,
Success: false,
Error: &helicopterfile.BulkItemError{
Status: status,
Title: title,
Detail: detail,
},
}
}
func createBulkError(index int, status int, title, detail string) helicopterfile.CreateBulkItemResult {
return helicopterfile.CreateBulkItemResult{
Index: index,
Success: false,
Error: &helicopterfile.BulkItemError{
Status: status,
Title: title,
Detail: detail,
},
}
}
func isValidHelicopterFileSection(section string) bool {
switch strings.TrimSpace(section) {
case helicopterfile.SectionBeforeFirstFlightInspection,
helicopterfile.SectionFlightPreparationAndWB,
helicopterfile.SectionAfterLastFlightInspection:
return true
default:
return false
}
}
func uuidBytesToStringOrEmpty(id []byte) string {
s, err := uuidv7.BytesToString(id)
if err != nil {
return ""
}
return s
}
func normalizeHelicopterFileSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortExpr("section ASC, position ASC, created_at ASC", "section DESC, position DESC, created_at DESC", "section"),
sortField("position"),
sortField("is_mandatory"),
sortField("created_at"),
sortField("updated_at"),
); normalized != "" {
return normalized
}
return "section ASC, position ASC, created_at ASC"
}

View File

@@ -0,0 +1,374 @@
package service
import (
"context"
"testing"
"time"
filemanager "wucher/internal/domain/file_manager"
helicopterfile "wucher/internal/domain/helicopter_file"
helicopterfilerealtime "wucher/internal/realtime/helicopterfile"
"wucher/internal/shared/pkg/uuidv7"
)
type helicopterFileServiceRepoStub struct {
listPendingFn func(context.Context, int) ([]helicopterfile.HelicopterFile, error)
listPendingBySourceFn func(context.Context, []byte, int) ([]helicopterfile.HelicopterFile, error)
updateFn func(context.Context, *helicopterfile.HelicopterFile) error
}
func (r *helicopterFileServiceRepoStub) Create(context.Context, *helicopterfile.HelicopterFile) error {
return nil
}
func (r *helicopterFileServiceRepoStub) Update(ctx context.Context, row *helicopterfile.HelicopterFile) error {
if r.updateFn != nil {
return r.updateFn(ctx, row)
}
return nil
}
func (r *helicopterFileServiceRepoStub) Delete(context.Context, []byte) error { return nil }
func (r *helicopterFileServiceRepoStub) GetByID(context.Context, []byte) (*helicopterfile.HelicopterFile, error) {
return nil, nil
}
func (r *helicopterFileServiceRepoStub) List(context.Context, string, string, int, int) ([]helicopterfile.HelicopterFile, int64, error) {
return nil, 0, nil
}
func (r *helicopterFileServiceRepoStub) ListPendingAttachments(ctx context.Context, limit int) ([]helicopterfile.HelicopterFile, error) {
if r.listPendingFn != nil {
return r.listPendingFn(ctx, limit)
}
return nil, nil
}
func (r *helicopterFileServiceRepoStub) ListPendingAttachmentsBySourceFileID(ctx context.Context, sourceFileID []byte, limit int) ([]helicopterfile.HelicopterFile, error) {
if r.listPendingBySourceFn != nil {
return r.listPendingBySourceFn(ctx, sourceFileID, limit)
}
return nil, nil
}
func (r *helicopterFileServiceRepoStub) ListByHelicopter(context.Context, []byte) ([]helicopterfile.HelicopterFile, error) {
return nil, nil
}
func (r *helicopterFileServiceRepoStub) ListByHelicopterAndSection(context.Context, []byte, string) ([]helicopterfile.HelicopterFile, error) {
return nil, nil
}
func (r *helicopterFileServiceRepoStub) HelicopterExists(context.Context, []byte) (bool, error) {
return false, nil
}
func (r *helicopterFileServiceRepoStub) AttachmentExists(context.Context, []byte) (bool, error) {
return false, nil
}
type helicopterFileServiceManagerStub struct {
filemanager.Service
createAttachmentFn func(context.Context, filemanager.CreateAttachmentParams) (*filemanager.Attachment, error)
getAttachmentByIDFn func(context.Context, []byte) (*filemanager.Attachment, error)
markFileActiveFn func(context.Context, []byte, []byte) error
createAttachmentArgs []filemanager.CreateAttachmentParams
markFileActiveArgs []struct {
fileID []byte
actorID []byte
}
}
func (m *helicopterFileServiceManagerStub) CreateAttachment(ctx context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
copied := params
copied.FileID = append([]byte(nil), params.FileID...)
copied.ActorID = append([]byte(nil), params.ActorID...)
m.createAttachmentArgs = append(m.createAttachmentArgs, copied)
if m.createAttachmentFn != nil {
return m.createAttachmentFn(ctx, params)
}
return nil, nil
}
func (m *helicopterFileServiceManagerStub) GetAttachmentByID(ctx context.Context, id []byte) (*filemanager.Attachment, error) {
if m.getAttachmentByIDFn != nil {
return m.getAttachmentByIDFn(ctx, id)
}
return nil, nil
}
func (m *helicopterFileServiceManagerStub) MarkFileActive(ctx context.Context, fileID []byte, actorID []byte) error {
m.markFileActiveArgs = append(m.markFileActiveArgs, struct {
fileID []byte
actorID []byte
}{fileID: append([]byte(nil), fileID...), actorID: append([]byte(nil), actorID...)})
if m.markFileActiveFn != nil {
return m.markFileActiveFn(ctx, fileID, actorID)
}
return nil
}
func (m *helicopterFileServiceManagerStub) ReconcileFileLifecycle(context.Context, []byte, []byte) error {
return nil
}
func (m *helicopterFileServiceManagerStub) CleanupOrphanPendingFiles(context.Context, time.Time, time.Duration, int, bool) (filemanager.FileCleanupStats, error) {
return filemanager.FileCleanupStats{}, nil
}
type helicopterFileServiceEventsStub struct {
publishedUsers []string
event *helicopterfilerealtime.Event
}
func (m *helicopterFileServiceEventsStub) PublishToUsers(userUUIDs []string, event helicopterfilerealtime.Event) int {
m.publishedUsers = append([]string(nil), userUUIDs...)
copied := event
m.event = &copied
return len(userUUIDs)
}
func TestHelicopterFileServiceFinalizePendingAttachmentsPublishesReadyEvent(t *testing.T) {
actorID := uuidv7.MustBytes()
helicopterID := uuidv7.MustBytes()
helicopterFileID := uuidv7.MustBytes()
sourceFileID := uuidv7.MustBytes()
attachmentID := uuidv7.MustBytes()
sourceFile := &filemanager.File{
ID: sourceFileID,
Name: "purchase_orders 1 (15).xlsx",
MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Status: filemanager.FileStatusReady,
}
repo := &helicopterFileServiceRepoStub{
listPendingFn: func(_ context.Context, limit int) ([]helicopterfile.HelicopterFile, error) {
if limit != 1 {
t.Fatalf("expected limit 1, got %d", limit)
}
return []helicopterfile.HelicopterFile{
{
ID: helicopterFileID,
HelicopterID: helicopterID,
SourceFileID: sourceFileID,
CreatedBy: actorID,
UpdatedBy: actorID,
SourceFile: sourceFile,
Position: 4,
IsMandatory: false,
Section: helicopterfile.SectionBeforeFirstFlightInspection,
UpdatedAt: time.Unix(100, 0).UTC(),
CreatedAt: time.Unix(90, 0).UTC(),
FileAttachmentID: nil,
},
}, nil
},
updateFn: func(_ context.Context, row *helicopterfile.HelicopterFile) error {
if row == nil {
t.Fatal("expected row to be updated")
}
if len(row.FileAttachmentID) != 16 {
t.Fatalf("expected attachment id to be set, got %d bytes", len(row.FileAttachmentID))
}
if len(row.SourceFileID) != 0 {
t.Fatalf("expected source file id to be cleared, got %d bytes", len(row.SourceFileID))
}
return nil
},
}
fm := &helicopterFileServiceManagerStub{
createAttachmentFn: func(_ context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
if got, want := uuidStringOrFail(t, params.FileID), uuidStringOrFail(t, sourceFileID); got != want {
t.Fatalf("unexpected file id: got %s want %s", got, want)
}
if got, want := params.RefType, "helicopter"; got != want {
t.Fatalf("unexpected ref type: got %q want %q", got, want)
}
if got, want := params.RefID, uuidStringOrFail(t, helicopterID); got != want {
t.Fatalf("unexpected ref id: got %q want %q", got, want)
}
if params.Category == nil || *params.Category != "helicopter_file" {
t.Fatalf("expected helicopter_file category, got %#v", params.Category)
}
return &filemanager.Attachment{
ID: attachmentID,
FileID: sourceFileID,
File: sourceFile,
}, nil
},
getAttachmentByIDFn: func(_ context.Context, id []byte) (*filemanager.Attachment, error) {
if got, want := uuidStringOrFail(t, id), uuidStringOrFail(t, attachmentID); got != want {
t.Fatalf("unexpected attachment lookup id: got %s want %s", got, want)
}
return &filemanager.Attachment{
ID: attachmentID,
FileID: sourceFileID,
File: sourceFile,
}, nil
},
}
events := &helicopterFileServiceEventsStub{}
svc := NewHelicopterFileService(repo).
WithFileManagerService(fm).
WithHelicopterFileEvents(events)
finalized, err := svc.FinalizePendingAttachments(context.Background(), 1)
if err != nil {
t.Fatalf("FinalizePendingAttachments returned error: %v", err)
}
if finalized != 1 {
t.Fatalf("expected one finalized attachment, got %d", finalized)
}
if len(fm.createAttachmentArgs) != 1 {
t.Fatalf("expected one attachment create call, got %d", len(fm.createAttachmentArgs))
}
if len(fm.markFileActiveArgs) != 1 {
t.Fatalf("expected one mark active call, got %d", len(fm.markFileActiveArgs))
}
if events.event == nil {
t.Fatal("expected helicopter file event to be published")
}
expectedUser := uuidStringOrFail(t, actorID)
if len(events.publishedUsers) != 1 || events.publishedUsers[0] != expectedUser {
t.Fatalf("unexpected published users: %#v", events.publishedUsers)
}
if events.event.EventType != helicopterfilerealtime.DefaultEventType {
t.Fatalf("expected default event type, got %q", events.event.EventType)
}
if events.event.HelicopterID != uuidStringOrFail(t, helicopterID) {
t.Fatalf("unexpected helicopter id: %+v", events.event)
}
if events.event.HelicopterFileID != uuidStringOrFail(t, helicopterFileID) {
t.Fatalf("unexpected helicopter file id: %+v", events.event)
}
if events.event.FileID != uuidStringOrFail(t, attachmentID) {
t.Fatalf("expected finalized event to point to attachment id, got %+v", events.event)
}
if events.event.AttachmentPending {
t.Fatalf("expected finalized event to mark attachment pending false, got %+v", events.event)
}
if events.event.Name != sourceFile.Name || events.event.MimeType != sourceFile.MimeType {
t.Fatalf("unexpected file metadata in event: %+v", events.event)
}
}
func TestHelicopterFileServiceFinalizePendingAttachmentsBySourceFileUUIDPublishesReadyEvent(t *testing.T) {
actorID := uuidv7.MustBytes()
helicopterID := uuidv7.MustBytes()
helicopterFileID := uuidv7.MustBytes()
sourceFileID := uuidv7.MustBytes()
attachmentID := uuidv7.MustBytes()
sourceFileUUID := uuidStringOrFail(t, sourceFileID)
sourceFile := &filemanager.File{
ID: sourceFileID,
Name: "purchase_orders 1 (15).xlsx",
MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Status: filemanager.FileStatusReady,
}
repo := &helicopterFileServiceRepoStub{
listPendingBySourceFn: func(_ context.Context, source []byte, limit int) ([]helicopterfile.HelicopterFile, error) {
if got, want := uuidStringOrFail(t, source), sourceFileUUID; got != want {
t.Fatalf("unexpected source file id: got %s want %s", got, want)
}
if limit != 1 {
t.Fatalf("expected limit 1, got %d", limit)
}
return []helicopterfile.HelicopterFile{
{
ID: helicopterFileID,
HelicopterID: helicopterID,
SourceFileID: sourceFileID,
CreatedBy: actorID,
UpdatedBy: actorID,
SourceFile: sourceFile,
Position: 4,
IsMandatory: false,
Section: helicopterfile.SectionBeforeFirstFlightInspection,
UpdatedAt: time.Unix(100, 0).UTC(),
CreatedAt: time.Unix(90, 0).UTC(),
FileAttachmentID: nil,
},
}, nil
},
updateFn: func(_ context.Context, row *helicopterfile.HelicopterFile) error {
if row == nil {
t.Fatal("expected row to be updated")
}
if len(row.FileAttachmentID) != 16 {
t.Fatalf("expected attachment id to be set, got %d bytes", len(row.FileAttachmentID))
}
if len(row.SourceFileID) != 0 {
t.Fatalf("expected source file id to be cleared, got %d bytes", len(row.SourceFileID))
}
return nil
},
}
fm := &helicopterFileServiceManagerStub{
createAttachmentFn: func(_ context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
if got, want := uuidStringOrFail(t, params.FileID), sourceFileUUID; got != want {
t.Fatalf("unexpected file id: got %s want %s", got, want)
}
return &filemanager.Attachment{
ID: attachmentID,
FileID: sourceFileID,
File: sourceFile,
}, nil
},
getAttachmentByIDFn: func(_ context.Context, id []byte) (*filemanager.Attachment, error) {
if got, want := uuidStringOrFail(t, id), uuidStringOrFail(t, attachmentID); got != want {
t.Fatalf("unexpected attachment lookup id: got %s want %s", got, want)
}
return &filemanager.Attachment{
ID: attachmentID,
FileID: sourceFileID,
File: sourceFile,
}, nil
},
}
events := &helicopterFileServiceEventsStub{}
svc := NewHelicopterFileService(repo).
WithFileManagerService(fm).
WithHelicopterFileEvents(events)
finalized, err := svc.FinalizePendingAttachmentsBySourceFileUUID(context.Background(), sourceFileUUID, 1)
if err != nil {
t.Fatalf("FinalizePendingAttachmentsBySourceFileUUID returned error: %v", err)
}
if finalized != 1 {
t.Fatalf("expected one finalized attachment, got %d", finalized)
}
if len(fm.createAttachmentArgs) != 1 {
t.Fatalf("expected one attachment create call, got %d", len(fm.createAttachmentArgs))
}
if len(fm.markFileActiveArgs) != 1 {
t.Fatalf("expected one mark active call, got %d", len(fm.markFileActiveArgs))
}
if events.event == nil {
t.Fatal("expected helicopter file event to be published")
}
if events.event.FileID != uuidStringOrFail(t, attachmentID) {
t.Fatalf("expected finalized event to point to attachment id, got %+v", events.event)
}
if events.event.AttachmentPending {
t.Fatalf("expected finalized event to mark attachment pending false, got %+v", events.event)
}
}
func uuidStringOrFail(t *testing.T, raw []byte) string {
t.Helper()
out, err := uuidv7.BytesToString(raw)
if err != nil {
t.Fatalf("convert uuid: %v", err)
}
return out
}

View File

@@ -0,0 +1,113 @@
package service
import (
"context"
"gorm.io/gorm"
fleetstatus "wucher/internal/domain/fleet_status"
"wucher/internal/domain/helicopter"
"wucher/internal/shared/pkg/sortkey"
)
type HelicopterService struct {
repo helicopter.Repository
txRepo helicopterTxRepository
}
func NewHelicopterService(repo helicopter.Repository) *HelicopterService {
svc := &HelicopterService{repo: repo}
if txRepo, ok := repo.(helicopterTxRepository); ok {
svc.txRepo = txRepo
}
return svc
}
type helicopterTxRepository interface {
helicopter.Repository
Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error
CreateWithDB(ctx context.Context, db *gorm.DB, h *helicopter.Helicopter) error
}
func (s *HelicopterService) Create(ctx context.Context, h *helicopter.Helicopter) error {
if h != nil {
h.EnforceAOGInactive()
}
if s.txRepo == nil {
if err := s.repo.Create(ctx, h); err != nil {
return err
}
return nil
}
return s.txRepo.Transaction(ctx, func(tx *gorm.DB) error {
if err := s.txRepo.CreateWithDB(ctx, tx, h); err != nil {
return err
}
if h != nil && len(h.ID) == 16 {
return tx.WithContext(ctx).Create(&fleetstatus.FleetStatus{HelicopterID: append([]byte(nil), h.ID...)}).Error
}
return nil
})
}
func (s *HelicopterService) Update(ctx context.Context, h *helicopter.Helicopter) error {
if h != nil {
h.EnforceAOGInactive()
}
return s.repo.Update(ctx, h)
}
func (s *HelicopterService) ExistsByIdentifier(ctx context.Context, identifier string, excludeID []byte) (bool, error) {
return s.repo.ExistsByIdentifier(ctx, identifier, excludeID)
}
func (s *HelicopterService) ApplySortKeyPatch(h *helicopter.Helicopter, patch helicopter.SortKeyPatch) {
if h == nil || !patch.Set {
return
}
if patch.Value == nil {
h.SortKey = nil
return
}
v := *patch.Value
h.SortKey = &v
}
func (s *HelicopterService) Delete(ctx context.Context, id []byte) error {
return s.repo.Delete(ctx, id)
}
func (s *HelicopterService) GetByID(ctx context.Context, id []byte) (*helicopter.Helicopter, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HelicopterService) List(ctx context.Context, filter string, statuses []string, sort string, limit, offset int, groundedIDs [][]byte) ([]helicopter.Helicopter, int64, error) {
return s.repo.List(ctx, filter, statuses, normalizeHelicopterSort(sort), limit, offset, groundedIDs)
}
func (s *HelicopterService) NextReportNumber(ctx context.Context, id []byte) (string, int, error) {
return s.repo.NextReportNumber(ctx, id)
}
func (s *HelicopterService) IsInUse(ctx context.Context, id []byte) (bool, error) {
return s.repo.IsInUse(ctx, id)
}
func (s *HelicopterService) InUseByAircraftIDs(ctx context.Context, ids [][]byte) (map[string]bool, error) {
return s.repo.InUseByAircraftIDs(ctx, ids)
}
func (s *HelicopterService) ActiveFlightIDByAircraftIDs(ctx context.Context, ids [][]byte) (map[string][]byte, error) {
return s.repo.ActiveFlightIDByAircraftIDs(ctx, ids)
}
func normalizeHelicopterSort(sort string) string {
return normalizeSortByRules(sort,
sortField("designation"),
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "designation", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "designation", true)),
"sortkey",
),
)
}

View File

@@ -0,0 +1,319 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/helicopter"
)
type helicopterRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
nextReportNumberCalled bool
isInUseCalled bool
inUseByIDsCalled bool
existsByIdentifier bool
existsCalled bool
lastCreate *helicopter.Helicopter
lastUpdate *helicopter.Helicopter
lastDeleteID []byte
lastGetByID []byte
lastListFilter string
lastListStatuses []string
lastListSort string
lastListLimit int
lastListOffset int
lastNextReportID []byte
lastIsInUseID []byte
lastInUseIDs [][]byte
getByIDResult *helicopter.Helicopter
listResult []helicopter.Helicopter
listTotal int64
nextReportNumberResult string
nextReportCounter int
isInUseResult bool
inUseByIDsResult map[string]bool
existsByIdentifierID string
existsByIdentifierSkip []byte
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
nextReportNumberErr error
isInUseErr error
inUseByIDsErr error
existsErr error
}
func (m *helicopterRepoMock) Create(_ context.Context, row *helicopter.Helicopter) error {
m.createCalled = true
m.lastCreate = row
return m.createErr
}
func (m *helicopterRepoMock) Update(_ context.Context, row *helicopter.Helicopter) error {
m.updateCalled = true
m.lastUpdate = row
return m.updateErr
}
func (m *helicopterRepoMock) Delete(_ context.Context, id []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
return m.deleteErr
}
func (m *helicopterRepoMock) GetByID(_ context.Context, id []byte) (*helicopter.Helicopter, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *helicopterRepoMock) List(_ context.Context, filter string, statuses []string, sort string, limit, offset int, _ [][]byte) ([]helicopter.Helicopter, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListStatuses = statuses
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func (m *helicopterRepoMock) NextReportNumber(_ context.Context, id []byte) (string, int, error) {
m.nextReportNumberCalled = true
m.lastNextReportID = id
return m.nextReportNumberResult, m.nextReportCounter, m.nextReportNumberErr
}
func (m *helicopterRepoMock) IsInUse(_ context.Context, id []byte) (bool, error) {
m.isInUseCalled = true
m.lastIsInUseID = append([]byte(nil), id...)
return m.isInUseResult, m.isInUseErr
}
func (m *helicopterRepoMock) InUseByAircraftIDs(_ context.Context, ids [][]byte) (map[string]bool, error) {
m.inUseByIDsCalled = true
m.lastInUseIDs = m.lastInUseIDs[:0]
for i := range ids {
m.lastInUseIDs = append(m.lastInUseIDs, append([]byte(nil), ids[i]...))
}
if m.inUseByIDsResult == nil {
return map[string]bool{}, m.inUseByIDsErr
}
return m.inUseByIDsResult, m.inUseByIDsErr
}
func (m *helicopterRepoMock) ActiveFlightIDByAircraftIDs(_ context.Context, _ [][]byte) (map[string][]byte, error) {
return map[string][]byte{}, nil
}
func (m *helicopterRepoMock) ExistsByIdentifier(_ context.Context, identifier string, excludeID []byte) (bool, error) {
m.existsCalled = true
m.existsByIdentifierID = identifier
m.existsByIdentifierSkip = append([]byte(nil), excludeID...)
return m.existsByIdentifier, m.existsErr
}
func TestNewHelicopterService(t *testing.T) {
repo := &helicopterRepoMock{}
svc := NewHelicopterService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestHelicopterServiceCreate(t *testing.T) {
repo := &helicopterRepoMock{}
svc := NewHelicopterService(repo)
row := &helicopter.Helicopter{Designation: "H145", Identifier: "PK-ABC", Type: "Twin"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHelicopterServiceUpdate(t *testing.T) {
repo := &helicopterRepoMock{}
svc := NewHelicopterService(repo)
row := &helicopter.Helicopter{Designation: "H145", Identifier: "PK-ABC", Type: "Twin"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHelicopterServiceDelete(t *testing.T) {
repo := &helicopterRepoMock{}
svc := NewHelicopterService(repo)
id := []byte("id")
if err := svc.Delete(context.Background(), id); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) {
t.Fatalf("expected delete id forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHelicopterServiceGetByID(t *testing.T) {
expected := &helicopter.Helicopter{Designation: "H145", Identifier: "PK-ABC", Type: "Twin"}
repo := &helicopterRepoMock{getByIDResult: expected}
svc := NewHelicopterService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHelicopterServiceList(t *testing.T) {
repo := &helicopterRepoMock{
listResult: []helicopter.Helicopter{{Designation: "H145", Identifier: "PK-ABC", Type: "Twin"}},
listTotal: 1,
}
svc := NewHelicopterService(repo)
rows, total, err := svc.List(context.Background(), "find", []string{"available", "booked"}, "-designation", 10, 20, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "designation DESC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
if len(repo.lastListStatuses) != 2 || repo.lastListStatuses[0] != "available" || repo.lastListStatuses[1] != "booked" {
t.Fatalf("unexpected status filter forwarded: %v", repo.lastListStatuses)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", nil, "unknown", 1, 0, nil); err == nil {
t.Fatalf("expected error from repo")
}
if repo.lastListSort != "" {
t.Fatalf("expected unknown sort normalized to empty")
}
}
func TestHelicopterServiceExistsByIdentifier(t *testing.T) {
repo := &helicopterRepoMock{existsByIdentifier: true}
svc := NewHelicopterService(repo)
excludeID := []byte("id")
exists, err := svc.ExistsByIdentifier(context.Background(), "SN-0001", excludeID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !exists {
t.Fatalf("expected duplicate exists result")
}
if !repo.existsCalled || repo.existsByIdentifierID != "SN-0001" || string(repo.existsByIdentifierSkip) != string(excludeID) {
t.Fatalf("expected ExistsByIdentifier forwarded")
}
repo.existsErr = errors.New("exists failed")
if _, err := svc.ExistsByIdentifier(context.Background(), "SN-0001", nil); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHelicopterServiceNextReportNumber(t *testing.T) {
repo := &helicopterRepoMock{
nextReportNumberResult: "RPT-0001",
nextReportCounter: 1,
}
svc := NewHelicopterService(repo)
id := []byte("id")
number, counter, err := svc.NextReportNumber(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if number != "RPT-0001" || counter != 1 {
t.Fatalf("unexpected next report result")
}
if !repo.nextReportNumberCalled || string(repo.lastNextReportID) != string(id) {
t.Fatalf("expected NextReportNumber forwarded")
}
repo.nextReportNumberErr = errors.New("next failed")
if _, _, err := svc.NextReportNumber(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeHelicopterSort(t *testing.T) {
cases := map[string]string{
"designation": "designation ASC",
"-designation": "designation DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, designation ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, designation ASC",
"identifier": "",
"-identifier": "",
"type": "",
"-type": "",
"counter": "",
"-counter": "",
"created_at": "",
"-created_at": "",
"updated_at": "",
"-updated_at": "",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeHelicopterSort(in); got != want {
t.Fatalf("normalizeHelicopterSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,89 @@
package service
import (
"context"
helicopterusage "wucher/internal/domain/helicopter_usage"
)
type HelicopterUsageService struct {
repo helicopterusage.Repository
}
func NewHelicopterUsageService(repo helicopterusage.Repository) *HelicopterUsageService {
return &HelicopterUsageService{repo: repo}
}
func (s *HelicopterUsageService) Create(ctx context.Context, row *helicopterusage.HelicopterUsage) error {
return s.repo.Create(ctx, row)
}
func (s *HelicopterUsageService) Update(ctx context.Context, row *helicopterusage.HelicopterUsage) error {
return s.repo.Update(ctx, row)
}
func (s *HelicopterUsageService) ReviseManualTotals(ctx context.Context, helicopterID []byte, in helicopterusage.ManualSummaryInput, actor []byte) error {
if len(helicopterID) != 16 {
return nil
}
row, err := s.repo.GetByHelicopterID(ctx, helicopterID)
if err != nil {
return err
}
if row == nil {
row = &helicopterusage.HelicopterUsage{HelicopterID: append([]byte(nil), helicopterID...), CreatedBy: actor}
}
row.ApplyManualSummary(in)
row.UpdatedBy = actor
if len(row.ID) == 0 {
return s.repo.Create(ctx, row)
}
return s.repo.Update(ctx, row)
}
func (s *HelicopterUsageService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *HelicopterUsageService) RefreshAll(ctx context.Context) error {
if refresher, ok := s.repo.(interface {
RefreshAll(context.Context) error
}); ok {
return refresher.RefreshAll(ctx)
}
return nil
}
func (s *HelicopterUsageService) GetByID(ctx context.Context, id []byte) (*helicopterusage.HelicopterUsage, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HelicopterUsageService) GetByHelicopterID(ctx context.Context, helicopterID []byte) (*helicopterusage.HelicopterUsage, error) {
return s.repo.GetByHelicopterID(ctx, helicopterID)
}
func (s *HelicopterUsageService) FlightStatsByFlightID(ctx context.Context, flightID []byte) (helicopterusage.FlightDayStats, error) {
return s.repo.FlightStatsByFlightID(ctx, flightID)
}
func (s *HelicopterUsageService) List(ctx context.Context, filter, sort string, limit, offset int) ([]helicopterusage.HelicopterUsage, int64, error) {
return s.repo.List(ctx, filter, normalizeSortByRules(sort,
sortField("helicopter_id"),
sortField("total_landing"),
sortField("total_airframe_hours"),
sortField("total_airframe_cycles"),
sortField("total_engine_1_hours"),
sortField("total_engine_1_gpc_ng_n1"),
sortField("total_engine_1_ptc_nf_n2"),
sortField("total_engine_1_ccc"),
sortField("total_engine_2_hours"),
sortField("total_engine_2_gpc_ng_n1"),
sortField("total_engine_2_ptc_nf_n2"),
sortField("total_engine_2_ccc"),
sortField("total_flight_report"),
sortField("total_hook_release"),
sortField("total_rotor_brake_cycle"),
sortField("created_at"),
sortField("updated_at"),
), limit, offset)
}

View File

@@ -0,0 +1,443 @@
package service
import (
"context"
"errors"
"net/http"
"strings"
"gorm.io/gorm"
"wucher/internal/domain/helicopter"
sharedconst "wucher/internal/shared/const"
"wucher/internal/shared/pkg/uuidv7"
)
type helicopterPINVerifier interface {
ConsumeSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error
}
type helicopterPINSessionVerifier interface {
AccessCookieName() string
ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error)
ConsumeSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error
}
type HelicopterWriteError struct {
Status int
Title string
Detail string
Pointer string
}
func (e *HelicopterWriteError) Error() string {
if e == nil {
return ""
}
return e.Detail
}
type HelicopterWriteResult struct {
Row *helicopter.Helicopter
IsInUse bool
PreviousFotoAttachmentID []byte
}
type HelicopterCreateInput struct {
ID []byte
Designation string
Identifier string
Type string
SortKey *int
Engine1 string
Engine2 string
Consumption float64
NR1 bool
NR2 bool
LH bool
RH bool
MGB bool
IGB bool
TGB bool
UTCOffset int
FotoAttachmentID []byte
Dry bool
AirOnGround bool
AOGReason string
AOGSource string
Note string
IsActive bool
ActorUserID []byte
PINVerifier helicopterPINVerifier
PINToken string
AccessToken string
}
type HelicopterUpdateInput struct {
ID []byte
Designation *string
Identifier *string
Type *string
SortKey helicopter.SortKeyPatch
Engine1 *string
Engine2 *string
Consumption *float64
NR1 *bool
NR2 *bool
LH *bool
RH *bool
MGB *bool
IGB *bool
TGB *bool
UTCOffset *int
FotoAttachmentID []byte
FotoAttachmentSet bool
Dry *bool
AirOnGround *bool
AOGReason *string
AOGSource *string
Note *string
IsActive *bool
ActorUserID []byte
PINVerifier helicopterPINVerifier
PINToken string
AccessToken string
}
func (s *HelicopterService) CreateDetailed(ctx context.Context, in HelicopterCreateInput) (*HelicopterWriteResult, error) {
if s == nil || s.repo == nil {
return nil, &HelicopterWriteError{Status: http.StatusInternalServerError, Title: "Create failed", Detail: "helicopter service is not configured"}
}
designation := strings.TrimSpace(in.Designation)
identifier := normalizeHelicopterIdentifier(in.Identifier)
heliType := strings.TrimSpace(in.Type)
if designation == "" || identifier == "" || heliType == "" {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "Validation error",
Detail: "designation, identifier, and type are required",
Pointer: "/data/attributes",
}
}
exists, err := s.repo.ExistsByIdentifier(ctx, identifier, nil)
if err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()}
}
if exists {
return nil, &HelicopterWriteError{
Status: http.StatusConflict,
Title: "Conflict",
Detail: "Serial number already exists",
Pointer: "/data/attributes/identifier",
}
}
aogReason := strings.TrimSpace(in.AOGReason)
if in.AirOnGround && aogReason == "" {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "AOG reason required",
Detail: "AOG reason must be provided when AOG is active (`aog=true`).",
Pointer: "/data/attributes/aog_reason",
}
}
if in.AirOnGround {
if err := verifyHelicopterAOGPIN(ctx, in.PINVerifier, in.AccessToken, in.ActorUserID, in.PINToken); err != nil {
return nil, err
}
}
row := &helicopter.Helicopter{
ID: append([]byte(nil), in.ID...),
Designation: designation,
Identifier: identifier,
ReportSequence: 0,
Type: heliType,
SortKey: cloneIntPtr(in.SortKey),
Engine1: strings.TrimSpace(in.Engine1),
Engine2: strings.TrimSpace(in.Engine2),
Consumption: in.Consumption,
NR1: in.NR1,
NR2: in.NR2,
LH: in.LH,
RH: in.RH,
MGB: in.MGB,
IGB: in.IGB,
TGB: in.TGB,
UTCOffset: in.UTCOffset,
FotoAttachmentID: append([]byte(nil), in.FotoAttachmentID...),
Dry: in.Dry,
AirOnGround: in.AirOnGround,
AOGReason: aogReason,
AOGSource: func() string {
if in.AirOnGround {
return helicopter.AOGSourceManual
}
return helicopter.AOGSourceUnknown
}(),
Note: strings.TrimSpace(in.Note),
IsActive: in.IsActive,
CreatedBy: append([]byte(nil), in.ActorUserID...),
UpdatedBy: append([]byte(nil), in.ActorUserID...),
}
if len(row.ID) == 0 {
row.ID = uuidv7.MustBytes()
}
if err := s.Create(ctx, row); err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()}
}
created, err := s.GetByID(ctx, row.ID)
if err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()}
}
if created == nil {
created = row
}
isInUse, err := s.IsInUse(ctx, row.ID)
if err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()}
}
return &HelicopterWriteResult{Row: created, IsInUse: isInUse}, nil
}
func (s *HelicopterService) UpdateDetailed(ctx context.Context, in HelicopterUpdateInput) (*HelicopterWriteResult, error) {
if s == nil || s.repo == nil {
return nil, &HelicopterWriteError{Status: http.StatusInternalServerError, Title: "Update failed", Detail: "helicopter service is not configured"}
}
if len(in.ID) != 16 {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "Validation error",
Detail: "id is invalid UUID",
Pointer: "/path/id",
}
}
existing, err := s.GetByID(ctx, in.ID)
if err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()}
}
if existing == nil {
return nil, &HelicopterWriteError{Status: http.StatusNotFound, Title: "Not found", Detail: "helicopter not found"}
}
prevAOG := existing.AirOnGround
prevFotoAttachmentID := append([]byte(nil), existing.FotoAttachmentID...)
if in.Designation != nil {
v := strings.TrimSpace(*in.Designation)
if v == "" {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "Validation error",
Detail: "designation cannot be empty",
Pointer: "/data/attributes/designation",
}
}
existing.Designation = v
}
if in.Identifier != nil {
raw := normalizeHelicopterIdentifier(*in.Identifier)
if raw == "" {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "Validation error",
Detail: "identifier cannot be empty",
Pointer: "/data/attributes/identifier",
}
}
exists, existsErr := s.repo.ExistsByIdentifier(ctx, raw, existing.ID)
if existsErr != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: existsErr.Error()}
}
if exists {
return nil, &HelicopterWriteError{
Status: http.StatusConflict,
Title: "Conflict",
Detail: "Serial number already exists",
Pointer: "/data/attributes/identifier",
}
}
existing.Identifier = raw
}
if in.Type != nil {
v := strings.TrimSpace(*in.Type)
if v == "" {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "Validation error",
Detail: "type cannot be empty",
Pointer: "/data/attributes/type",
}
}
existing.Type = v
}
s.ApplySortKeyPatch(existing, in.SortKey)
if in.Engine1 != nil {
existing.Engine1 = strings.TrimSpace(*in.Engine1)
}
if in.Engine2 != nil {
existing.Engine2 = strings.TrimSpace(*in.Engine2)
}
if in.Consumption != nil {
existing.Consumption = *in.Consumption
}
if in.NR1 != nil {
existing.NR1 = *in.NR1
}
if in.NR2 != nil {
existing.NR2 = *in.NR2
}
if in.LH != nil {
existing.LH = *in.LH
}
if in.RH != nil {
existing.RH = *in.RH
}
if in.MGB != nil {
existing.MGB = *in.MGB
}
if in.IGB != nil {
existing.IGB = *in.IGB
}
if in.TGB != nil {
existing.TGB = *in.TGB
}
if in.UTCOffset != nil {
existing.UTCOffset = *in.UTCOffset
}
if in.FotoAttachmentSet {
if len(in.FotoAttachmentID) == 0 {
existing.FotoAttachmentID = nil
} else {
existing.FotoAttachmentID = append([]byte(nil), in.FotoAttachmentID...)
}
}
if in.Dry != nil {
existing.Dry = *in.Dry
}
if in.AirOnGround != nil {
existing.AirOnGround = *in.AirOnGround
if *in.AirOnGround {
existing.AOGSource = helicopter.AOGSourceManual
}
}
if in.AOGReason != nil {
existing.AOGReason = strings.TrimSpace(*in.AOGReason)
}
if in.AOGSource != nil {
existing.AOGSource = strings.TrimSpace(*in.AOGSource)
}
if existing.AirOnGround && strings.TrimSpace(existing.AOGReason) == "" {
return nil, &HelicopterWriteError{
Status: http.StatusUnprocessableEntity,
Title: "AOG reason required",
Detail: "AOG reason must be provided when AOG is active (`aog=true`).",
Pointer: "/data/attributes/aog_reason",
}
}
if in.AirOnGround != nil && *in.AirOnGround != prevAOG {
if err := verifyHelicopterAOGPIN(ctx, in.PINVerifier, in.AccessToken, in.ActorUserID, in.PINToken); err != nil {
return nil, err
}
}
if in.Note != nil {
existing.Note = strings.TrimSpace(*in.Note)
}
if in.IsActive != nil {
existing.IsActive = *in.IsActive
}
existing.UpdatedBy = append([]byte(nil), in.ActorUserID...)
if err := s.Update(ctx, existing); err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()}
}
updated, err := s.GetByID(ctx, existing.ID)
if err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()}
}
if updated == nil {
updated = existing
}
isInUse, err := s.IsInUse(ctx, existing.ID)
if err != nil {
return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()}
}
return &HelicopterWriteResult{Row: updated, IsInUse: isInUse, PreviousFotoAttachmentID: prevFotoAttachmentID}, nil
}
func verifyHelicopterAOGPIN(
ctx context.Context,
verifier helicopterPINVerifier,
accessToken string,
userID []byte,
token string,
) error {
if verifier == nil {
return &HelicopterWriteError{
Status: http.StatusUnauthorized,
Title: "Unauthorized",
Detail: "auth service not configured",
}
}
if len(userID) == 0 {
return &HelicopterWriteError{
Status: http.StatusUnauthorized,
Title: "Unauthorized",
Detail: "missing auth context",
}
}
if sessionVerifier, ok := verifier.(helicopterPINSessionVerifier); ok {
sessionID := ""
if accessToken = strings.TrimSpace(accessToken); accessToken != "" {
sid, err := sessionVerifier.ParseAccessTokenSessionID(ctx, accessToken)
if err != nil {
return &HelicopterWriteError{
Status: http.StatusUnauthorized,
Title: "Unauthorized",
Detail: "invalid or expired token",
}
}
sessionID = sid
}
if err := sessionVerifier.ConsumeSecurityPINActionTokenInSession(ctx, userID, sharedconst.PinActionHelicopterAOGChange, token, sessionID); err != nil {
return &HelicopterWriteError{
Status: http.StatusAccepted,
Title: "PIN verification required",
Detail: "valid PIN action token is required",
Pointer: "/headers/X-PIN-Verification-Token",
}
}
return nil
}
if err := verifier.ConsumeSecurityPINActionToken(ctx, userID, sharedconst.PinActionHelicopterAOGChange, token); err != nil {
return &HelicopterWriteError{
Status: http.StatusAccepted,
Title: "PIN verification required",
Detail: "valid PIN action token is required",
Pointer: "/headers/X-PIN-Verification-Token",
}
}
return nil
}
func normalizeHelicopterIdentifier(raw string) string {
return strings.ToUpper(strings.TrimSpace(raw))
}
func cloneIntPtr(v *int) *int {
if v == nil {
return nil
}
out := *v
return &out
}
func isHelicopterNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}

View File

@@ -0,0 +1,44 @@
package service
import (
"context"
"wucher/internal/domain/operation"
)
type HEMSOperationCategoryService struct {
repo operation.HEMSOperationCategoryRepository
}
func NewHEMSOperationCategoryService(repo operation.HEMSOperationCategoryRepository) *HEMSOperationCategoryService {
return &HEMSOperationCategoryService{repo: repo}
}
func (s *HEMSOperationCategoryService) Create(ctx context.Context, category *operation.HEMSOperationCategory) error {
return s.repo.Create(ctx, category)
}
func (s *HEMSOperationCategoryService) Update(ctx context.Context, category *operation.HEMSOperationCategory) error {
return s.repo.Update(ctx, category)
}
func (s *HEMSOperationCategoryService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *HEMSOperationCategoryService) GetByID(ctx context.Context, id []byte) (*operation.HEMSOperationCategory, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HEMSOperationCategoryService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]operation.HEMSOperationCategory, int64, error) {
return s.repo.List(ctx, filter, normalizeHEMSOperationCategorySort(sort), limit, offset)
}
func normalizeHEMSOperationCategorySort(sort string) string {
return normalizeSortByRules(sort,
sortField("name"),
sortField("type"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,43 @@
package service
import (
"context"
"wucher/internal/domain/operation"
)
type HEMSOperationService struct {
repo operation.HEMSOperationRepository
}
func NewHEMSOperationService(repo operation.HEMSOperationRepository) *HEMSOperationService {
return &HEMSOperationService{repo: repo}
}
func (s *HEMSOperationService) Create(ctx context.Context, row *operation.HEMSOperation) error {
return s.repo.Create(ctx, row)
}
func (s *HEMSOperationService) Update(ctx context.Context, row *operation.HEMSOperation) error {
return s.repo.Update(ctx, row)
}
func (s *HEMSOperationService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *HEMSOperationService) GetByID(ctx context.Context, id []byte) (*operation.HEMSOperation, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HEMSOperationService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]operation.HEMSOperation, int64, error) {
return s.repo.List(ctx, filter, normalizeHEMSOperationSort(sort), limit, offset)
}
func normalizeHEMSOperationSort(sort string) string {
return normalizeSortByRules(sort,
sortField("date"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,44 @@
package service
import (
"context"
"wucher/internal/domain/operation"
)
type HEMSOperationalDataService struct {
repo operation.HEMSOperationalDataRepository
}
func NewHEMSOperationalDataService(repo operation.HEMSOperationalDataRepository) *HEMSOperationalDataService {
return &HEMSOperationalDataService{repo: repo}
}
func (s *HEMSOperationalDataService) Create(ctx context.Context, row *operation.HEMSOperationalData) error {
return s.repo.Create(ctx, row)
}
func (s *HEMSOperationalDataService) Update(ctx context.Context, row *operation.HEMSOperationalData) error {
return s.repo.Update(ctx, row)
}
func (s *HEMSOperationalDataService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *HEMSOperationalDataService) GetByID(ctx context.Context, id []byte) (*operation.HEMSOperationalData, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HEMSOperationalDataService) List(ctx context.Context, filter, sort string, limit, offset int) ([]operation.HEMSOperationalData, int64, error) {
return s.repo.List(ctx, filter, normalizeHEMSOperationalDataSort(sort), limit, offset)
}
func normalizeHEMSOperationalDataSort(sort string) string {
return normalizeSortByRules(sort,
sortField("time"),
sortField("location"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,55 @@
package service
import (
"context"
"wucher/internal/domain/hospital"
"wucher/internal/shared/pkg/sortkey"
)
type HospitalService struct {
repo hospital.Repository
}
func NewHospitalService(repo hospital.Repository) *HospitalService {
return &HospitalService{repo: repo}
}
func (s *HospitalService) Create(ctx context.Context, row *hospital.Hospital) error {
return s.repo.Create(ctx, row)
}
func (s *HospitalService) Update(ctx context.Context, row *hospital.Hospital) error {
return s.repo.Update(ctx, row)
}
func (s *HospitalService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *HospitalService) GetByID(ctx context.Context, id []byte) (*hospital.Hospital, error) {
return s.repo.GetByID(ctx, id)
}
func (s *HospitalService) List(ctx context.Context, filter, sort string, limit, offset int) ([]hospital.Hospital, int64, error) {
return s.repo.List(ctx, filter, normalizeHospitalSort(sort), limit, offset)
}
func normalizeHospitalSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "hospital_name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "hospital_name", true)),
"sortkey",
),
sortField("hospital_name", "name", "hospital_name"),
sortField("address"),
sortField("landline_number"),
sortField("mobile_number"),
sortField("email"),
sortField("note"),
sortField("created_at"),
sortField("updated_at"),
sortField("is_active"),
)
}

View File

@@ -0,0 +1,233 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/hospital"
)
type hospitalRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *hospital.Hospital
lastUpdate *hospital.Hospital
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *hospital.Hospital
listResult []hospital.Hospital
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *hospitalRepoMock) Create(_ context.Context, row *hospital.Hospital) error {
m.createCalled = true
m.lastCreate = row
return m.createErr
}
func (m *hospitalRepoMock) Update(_ context.Context, row *hospital.Hospital) error {
m.updateCalled = true
m.lastUpdate = row
return m.updateErr
}
func (m *hospitalRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *hospitalRepoMock) GetByID(_ context.Context, id []byte) (*hospital.Hospital, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *hospitalRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]hospital.Hospital, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewHospitalService(t *testing.T) {
repo := &hospitalRepoMock{}
svc := NewHospitalService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestHospitalServiceCreate(t *testing.T) {
repo := &hospitalRepoMock{}
svc := NewHospitalService(repo)
row := &hospital.Hospital{Name: "RSUD Kota", Address: "Jl. Merdeka"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHospitalServiceUpdate(t *testing.T) {
repo := &hospitalRepoMock{}
svc := NewHospitalService(repo)
row := &hospital.Hospital{Name: "RSUD Kota", Address: "Jl. Baru"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHospitalServiceDelete(t *testing.T) {
repo := &hospitalRepoMock{}
svc := NewHospitalService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHospitalServiceGetByID(t *testing.T) {
expected := &hospital.Hospital{Name: "RSUD Kota", Address: "Jl. Merdeka"}
repo := &hospitalRepoMock{getByIDResult: expected}
svc := NewHospitalService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestHospitalServiceList(t *testing.T) {
repo := &hospitalRepoMock{
listResult: []hospital.Hospital{{Name: "RSUD Kota", Address: "Jl. Merdeka"}},
listTotal: 1,
}
svc := NewHospitalService(repo)
rows, total, err := svc.List(context.Background(), "find", "name", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "hospital_name ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, hospital_name ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeHospitalSort(t *testing.T) {
cases := map[string]string{
"name": "hospital_name ASC",
"-name": "hospital_name DESC",
"hospital_name": "hospital_name ASC",
"-hospital_name": "hospital_name DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, hospital_name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, hospital_name ASC",
"address": "address ASC",
"-address": "address DESC",
"landline_number": "landline_number ASC",
"-landline_number": "landline_number DESC",
"mobile_number": "mobile_number ASC",
"-mobile_number": "mobile_number DESC",
"email": "email ASC",
"-email": "email DESC",
"note": "note ASC",
"-note": "note DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeHospitalSort(in); got != want {
t.Fatalf("normalizeHospitalSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,56 @@
package service
import (
"context"
"wucher/internal/domain/icao"
"wucher/internal/shared/pkg/sortkey"
)
type ICAOService struct {
repo icao.Repository
}
func NewICAOService(repo icao.Repository) *ICAOService {
return &ICAOService{repo: repo}
}
func (s *ICAOService) Create(ctx context.Context, row *icao.ICAO) error {
return s.repo.Create(ctx, row)
}
func (s *ICAOService) Update(ctx context.Context, row *icao.ICAO) error {
return s.repo.Update(ctx, row)
}
func (s *ICAOService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *ICAOService) GetByID(ctx context.Context, id []byte) (*icao.ICAO, error) {
return s.repo.GetByID(ctx, id)
}
func (s *ICAOService) List(ctx context.Context, filter, sort string, limit, offset int) ([]icao.ICAO, int64, error) {
return s.repo.List(ctx, filter, normalizeICAOSort(sort), limit, offset)
}
func normalizeICAOSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "icao_code", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "icao_code", true)),
"sortkey",
),
sortField("name"),
sortField("icao_code"),
sortField("address"),
sortField("landline_number"),
sortField("mobile_number"),
sortField("email"),
sortField("note"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,231 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/icao"
)
type icaoRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *icao.ICAO
lastUpdate *icao.ICAO
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *icao.ICAO
listResult []icao.ICAO
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *icaoRepoMock) Create(_ context.Context, o *icao.ICAO) error {
m.createCalled = true
m.lastCreate = o
return m.createErr
}
func (m *icaoRepoMock) Update(_ context.Context, o *icao.ICAO) error {
m.updateCalled = true
m.lastUpdate = o
return m.updateErr
}
func (m *icaoRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *icaoRepoMock) GetByID(_ context.Context, id []byte) (*icao.ICAO, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *icaoRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]icao.ICAO, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewICAOService(t *testing.T) {
repo := &icaoRepoMock{}
svc := NewICAOService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestICAOServiceCreate(t *testing.T) {
repo := &icaoRepoMock{}
svc := NewICAOService(repo)
row := &icao.ICAO{ICAOCode: "EDDB"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestICAOServiceUpdate(t *testing.T) {
repo := &icaoRepoMock{}
svc := NewICAOService(repo)
row := &icao.ICAO{ICAOCode: "EDDF"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestICAOServiceDelete(t *testing.T) {
repo := &icaoRepoMock{}
svc := NewICAOService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestICAOServiceGetByID(t *testing.T) {
expected := &icao.ICAO{ICAOCode: "A"}
repo := &icaoRepoMock{getByIDResult: expected}
svc := NewICAOService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestICAOServiceList(t *testing.T) {
repo := &icaoRepoMock{
listResult: []icao.ICAO{{ICAOCode: "A"}},
listTotal: 1,
}
svc := NewICAOService(repo)
rows, total, err := svc.List(context.Background(), "find", "icao_code", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "icao_code ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, icao_code ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeICAOSort(t *testing.T) {
cases := map[string]string{
"icao_code": "icao_code ASC",
"-icao_code": "icao_code DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, icao_code ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, icao_code ASC",
"address": "address ASC",
"-address": "address DESC",
"landline_number": "landline_number ASC",
"-landline_number": "landline_number DESC",
"mobile_number": "mobile_number ASC",
"-mobile_number": "mobile_number DESC",
"email": "email ASC",
"-email": "email DESC",
"note": "note ASC",
"-note": "note DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeICAOSort(in); got != want {
t.Fatalf("normalizeICAOSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,42 @@
package service
import (
"context"
insurancepatientdata "wucher/internal/domain/insurance_patient_data"
)
type InsurancePatientDataService struct {
repo insurancepatientdata.Repository
}
func NewInsurancePatientDataService(repo insurancepatientdata.Repository) *InsurancePatientDataService {
return &InsurancePatientDataService{repo: repo}
}
func (s *InsurancePatientDataService) Create(ctx context.Context, row *insurancepatientdata.InsurancePatientData) error {
return s.repo.Create(ctx, row)
}
func (s *InsurancePatientDataService) Update(ctx context.Context, row *insurancepatientdata.InsurancePatientData) error {
return s.repo.Update(ctx, row)
}
func (s *InsurancePatientDataService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *InsurancePatientDataService) GetByID(ctx context.Context, id []byte) (*insurancepatientdata.InsurancePatientData, error) {
return s.repo.GetByID(ctx, id)
}
func (s *InsurancePatientDataService) List(ctx context.Context, filter, sort string, limit, offset int) ([]insurancepatientdata.InsurancePatientData, int64, error) {
return s.repo.List(ctx, filter, normalizeInsurancePatientDataSort(sort), limit, offset)
}
func normalizeInsurancePatientDataSort(sort string) string {
return normalizeSortByRules(sort,
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,81 @@
package service
import (
"context"
"wucher/internal/domain/land"
"wucher/internal/shared/pkg/sortkey"
"wucher/internal/transport/http/dto"
)
type LandService struct {
repo land.Repository
}
func NewLandService(repo land.Repository) *LandService {
return &LandService{repo: repo}
}
func (s *LandService) Create(ctx context.Context, row *land.Land) error {
return s.repo.Create(ctx, row)
}
func (s *LandService) Update(ctx context.Context, row *land.Land) error {
return s.repo.Update(ctx, row)
}
func (s *LandService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *LandService) GetByID(ctx context.Context, id []byte) (*land.Land, error) {
return s.repo.GetByID(ctx, id)
}
func (s *LandService) List(ctx context.Context, filter, sort string, limit, offset int) ([]land.Land, int64, error) {
return s.repo.List(ctx, filter, normalizeLandSort(sort), limit, offset)
}
func (s *LandService) GetByIDView(ctx context.Context, id []byte) (*dto.LandView, error) {
if repo, ok := s.repo.(interface {
GetByIDView(context.Context, []byte) (*dto.LandView, error)
}); ok {
return repo.GetByIDView(ctx, id)
}
row, err := s.repo.GetByID(ctx, id)
if err != nil || row == nil {
return nil, err
}
return &dto.LandView{Row: *row}, nil
}
func (s *LandService) ListView(ctx context.Context, filter, sort string, limit, offset int) ([]dto.LandView, int64, error) {
if repo, ok := s.repo.(interface {
ListView(context.Context, string, string, int, int) ([]dto.LandView, int64, error)
}); ok {
return repo.ListView(ctx, filter, normalizeLandSort(sort), limit, offset)
}
rows, total, err := s.repo.List(ctx, filter, normalizeLandSort(sort), limit, offset)
if err != nil {
return nil, 0, err
}
views := make([]dto.LandView, 0, len(rows))
for i := range rows {
views = append(views, dto.LandView{Row: rows[i]})
}
return views, total, nil
}
func normalizeLandSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("name"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,221 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/land"
)
type landRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *land.Land
lastUpdate *land.Land
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *land.Land
listResult []land.Land
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *landRepoMock) Create(_ context.Context, o *land.Land) error {
m.createCalled = true
m.lastCreate = o
return m.createErr
}
func (m *landRepoMock) Update(_ context.Context, o *land.Land) error {
m.updateCalled = true
m.lastUpdate = o
return m.updateErr
}
func (m *landRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *landRepoMock) GetByID(_ context.Context, id []byte) (*land.Land, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *landRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]land.Land, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewLandService(t *testing.T) {
repo := &landRepoMock{}
svc := NewLandService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestLandServiceCreate(t *testing.T) {
repo := &landRepoMock{}
svc := NewLandService(repo)
row := &land.Land{Name: "Germany"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestLandServiceUpdate(t *testing.T) {
repo := &landRepoMock{}
svc := NewLandService(repo)
row := &land.Land{Name: "Updated"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestLandServiceDelete(t *testing.T) {
repo := &landRepoMock{}
svc := NewLandService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestLandServiceGetByID(t *testing.T) {
expected := &land.Land{Name: "A"}
repo := &landRepoMock{getByIDResult: expected}
svc := NewLandService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestLandServiceList(t *testing.T) {
repo := &landRepoMock{
listResult: []land.Land{{Name: "A"}},
listTotal: 1,
}
svc := NewLandService(repo)
rows, total, err := svc.List(context.Background(), "find", "name", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "name ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeLandSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, name ASC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeLandSort(in); got != want {
t.Fatalf("normalizeLandSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,439 @@
package service
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"wucher/internal/config"
mastersettings "wucher/internal/domain/master_settings"
)
type MasterSettingsService struct {
repo mastersettings.Repository
protector SecretProtector
}
type MasterSettingsServiceDependencies struct {
Protector SecretProtector
}
func NewMasterSettingsService(repo mastersettings.Repository, deps MasterSettingsServiceDependencies) *MasterSettingsService {
return &MasterSettingsService{
repo: repo,
protector: deps.Protector,
}
}
func (s *MasterSettingsService) SetSecretProtector(protector SecretProtector) *MasterSettingsService {
if s == nil {
return nil
}
s.protector = protector
return s
}
func (s *MasterSettingsService) Create(ctx context.Context, row *mastersettings.MasterSettings) error {
return s.repo.Create(ctx, row)
}
func (s *MasterSettingsService) Update(ctx context.Context, row *mastersettings.MasterSettings) error {
return s.repo.Update(ctx, row)
}
func (s *MasterSettingsService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *MasterSettingsService) GetByID(ctx context.Context, id []byte) (*mastersettings.MasterSettings, error) {
return s.repo.GetByID(ctx, id)
}
func (s *MasterSettingsService) List(ctx context.Context, filter, sort string, limit, offset int) ([]mastersettings.MasterSettings, int64, error) {
return s.repo.List(ctx, filter, normalizeMasterSettingsSort(sort), limit, offset)
}
func (s *MasterSettingsService) UpsertMicrosoftEntraConfig(
ctx context.Context,
input mastersettings.MicrosoftEntraConfigInput,
actor []byte,
) (*mastersettings.MicrosoftEntraConfigView, error) {
normalized, err := normalizeMicrosoftEntraConfigInput(input)
if err != nil {
return nil, err
}
rows := []mastersettings.MasterSettingValue{
{
SettingKey: mastersettings.SettingMicrosoftEntraTenantID,
Value: normalized.TenantID,
CreatedBy: actor,
UpdatedBy: actor,
IsEncrypted: false,
},
{
SettingKey: mastersettings.SettingMicrosoftEntraClientID,
Value: normalized.ClientID,
CreatedBy: actor,
UpdatedBy: actor,
IsEncrypted: false,
},
{
SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL,
Value: normalized.RedirectURL,
CreatedBy: actor,
UpdatedBy: actor,
IsEncrypted: false,
},
{
SettingKey: mastersettings.SettingMicrosoftEntraAuthority,
Value: normalized.Authority,
CreatedBy: actor,
UpdatedBy: actor,
IsEncrypted: false,
},
{
SettingKey: mastersettings.SettingMicrosoftEntraScopes,
Value: normalized.Scopes,
CreatedBy: actor,
UpdatedBy: actor,
IsEncrypted: false,
},
}
if normalized.ClientSecret != "" {
if s.protector == nil {
return nil, errors.New("secret protector not configured")
}
encryptedSecret, err := s.protector.Encrypt([]byte(normalized.ClientSecret))
if err != nil {
return nil, fmt.Errorf("encrypt MS_ENTRA_CLIENT_SECRET: %w", err)
}
encodedSecret := base64.StdEncoding.EncodeToString(encryptedSecret)
rows = append(rows, mastersettings.MasterSettingValue{
SettingKey: mastersettings.SettingMicrosoftEntraClientSecret,
Value: encodedSecret,
CreatedBy: actor,
UpdatedBy: actor,
IsEncrypted: true,
})
} else {
existingSecretRows, err := s.repo.GetSettingValuesByKeys(ctx, []string{mastersettings.SettingMicrosoftEntraClientSecret})
if err != nil {
return nil, err
}
_, _, existingSecret, _, _, _, _ := extractMicrosoftEntraValues(existingSecretRows)
if strings.TrimSpace(existingSecret) == "" {
return nil, errors.New("MS_ENTRA_CLIENT_SECRET is required for initial setup")
}
}
if err := s.repo.UpsertSettingValues(ctx, rows); err != nil {
return nil, err
}
return s.GetMicrosoftEntraConfig(ctx)
}
func (s *MasterSettingsService) GetMicrosoftEntraConfig(ctx context.Context) (*mastersettings.MicrosoftEntraConfigView, error) {
rows, err := s.repo.GetSettingValuesByKeys(ctx, mastersettings.MicrosoftEntraSettingKeys)
if err != nil {
return nil, err
}
tenant, clientID, secret, redirectURL, authority, scopes, encrypted := extractMicrosoftEntraValues(rows)
// UI view supports partial config so non-sensitive bootstrapped keys can be rendered.
// Runtime login flow still uses LoadMicrosoftEntraRuntimeConfig, which remains strict.
if redirectURL == "" || authority == "" || scopes == "" {
return nil, nil
}
secretMasked := ""
if secret != "" && encrypted {
secretMasked = "********"
} else if secret != "" {
secretMasked = maskMicrosoftSecret(secret)
}
return &mastersettings.MicrosoftEntraConfigView{
TenantID: tenant,
ClientID: clientID,
ClientSecretMasked: secretMasked,
RedirectURL: redirectURL,
Authority: authority,
Scopes: scopes,
}, nil
}
func (s *MasterSettingsService) LoadMicrosoftEntraRuntimeConfig(ctx context.Context) (config.MicrosoftSSOConfig, error) {
rows, err := s.repo.GetSettingValuesByKeys(ctx, mastersettings.MicrosoftEntraSettingKeys)
if err != nil {
return config.MicrosoftSSOConfig{}, err
}
tenant, clientID, secret, redirectURL, authority, scopes, encrypted := extractMicrosoftEntraValues(rows)
if tenant == "" || clientID == "" || secret == "" || redirectURL == "" || authority == "" || scopes == "" {
return config.MicrosoftSSOConfig{}, errors.New("sso not configured")
}
plainSecret := secret
if encrypted {
if s.protector == nil {
return config.MicrosoftSSOConfig{}, errors.New("secret protector not configured")
}
cipherBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
return config.MicrosoftSSOConfig{}, errors.New("invalid encrypted MS_ENTRA_CLIENT_SECRET")
}
plainBytes, err := s.protector.Decrypt(cipherBytes)
if err != nil {
return config.MicrosoftSSOConfig{}, errors.New("failed to decrypt MS_ENTRA_CLIENT_SECRET")
}
plainSecret = strings.TrimSpace(string(plainBytes))
}
if plainSecret == "" {
return config.MicrosoftSSOConfig{}, errors.New("sso not configured")
}
return config.MicrosoftSSOConfig{
TenantID: tenant,
ClientID: clientID,
ClientSecret: plainSecret,
RedirectURL: redirectURL,
Authority: authority,
Scopes: splitCSV(scopes),
}, nil
}
func (s *MasterSettingsService) IsLoginEmailOTPEnabled(ctx context.Context) (bool, error) {
if s == nil || s.repo == nil {
return true, nil
}
rows, err := s.repo.GetSettingValuesByKeys(ctx, []string{mastersettings.SettingAuthLoginEmailOTPEnabled})
if err != nil {
return true, err
}
if len(rows) == 0 {
return true, nil
}
raw := strings.TrimSpace(rows[0].Value)
if raw == "" {
return true, nil
}
enabled, parseErr := strconv.ParseBool(raw)
if parseErr != nil {
return true, nil
}
return enabled, nil
}
// SeedMicrosoftEntraConfigIfMissing backfills missing non-sensitive SSO setting keys from app config.
// Existing non-empty values are preserved.
func (s *MasterSettingsService) SeedMicrosoftEntraConfigIfMissing(
ctx context.Context,
cfg config.MicrosoftSSOConfig,
) error {
if s == nil || s.repo == nil {
return nil
}
rows, err := s.repo.GetSettingValuesByKeys(ctx, mastersettings.MicrosoftEntraSettingKeys)
if err != nil {
return err
}
existing := make(map[string]mastersettings.MasterSettingValue, len(rows))
for i := range rows {
key := strings.TrimSpace(rows[i].SettingKey)
if key == "" {
continue
}
existing[key] = rows[i]
}
isMissing := func(key string) bool {
row, ok := existing[key]
if !ok {
return true
}
return strings.TrimSpace(row.Value) == ""
}
normalized, err := normalizeMicrosoftEntraConfigInput(mastersettings.MicrosoftEntraConfigInput{
// Only validate and seed non-sensitive fields.
// Tenant ID, Client ID, and Client Secret are intentionally excluded.
TenantID: "placeholder",
ClientID: "placeholder",
ClientSecret: "placeholder",
RedirectURL: cfg.RedirectURL,
Authority: cfg.Authority,
Scopes: strings.Join(cfg.Scopes, ","),
})
if err != nil {
return err
}
insertRows := make([]mastersettings.MasterSettingValue, 0, 3)
if isMissing(mastersettings.SettingMicrosoftEntraRedirectURL) {
insertRows = append(insertRows, mastersettings.MasterSettingValue{
SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL,
Value: normalized.RedirectURL,
IsEncrypted: false,
})
}
if isMissing(mastersettings.SettingMicrosoftEntraAuthority) {
insertRows = append(insertRows, mastersettings.MasterSettingValue{
SettingKey: mastersettings.SettingMicrosoftEntraAuthority,
Value: normalized.Authority,
IsEncrypted: false,
})
}
if isMissing(mastersettings.SettingMicrosoftEntraScopes) {
insertRows = append(insertRows, mastersettings.MasterSettingValue{
SettingKey: mastersettings.SettingMicrosoftEntraScopes,
Value: normalized.Scopes,
IsEncrypted: false,
})
}
if len(insertRows) == 0 {
return nil
}
return s.repo.UpsertSettingValues(ctx, insertRows)
}
func normalizeMasterSettingsSort(sort string) string {
return normalizeSortByRules(sort,
sortField("company_name"),
sortField("title"),
sortField("subtitle"),
sortField("logo_url"),
sortField("logo_file_id", "logo_file_uuid"),
sortField("created_at"),
sortField("updated_at"),
)
}
func normalizeMicrosoftEntraConfigInput(input mastersettings.MicrosoftEntraConfigInput) (mastersettings.MicrosoftEntraConfigInput, error) {
normalized := mastersettings.MicrosoftEntraConfigInput{
TenantID: strings.TrimSpace(input.TenantID),
ClientID: strings.TrimSpace(input.ClientID),
ClientSecret: strings.TrimSpace(input.ClientSecret),
RedirectURL: strings.TrimSpace(input.RedirectURL),
Authority: strings.TrimSpace(input.Authority),
Scopes: normalizeCSV(input.Scopes),
}
if normalized.TenantID == "" {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_TENANT_ID is required")
}
if normalized.ClientID == "" {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_CLIENT_ID is required")
}
if normalized.RedirectURL == "" {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_REDIRECT_URL is required")
}
if normalized.Authority == "" {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_AUTHORITY is required")
}
if normalized.Scopes == "" {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_SCOPES is required")
}
if !isValidHTTPURL(normalized.RedirectURL) {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_REDIRECT_URL must be a valid absolute URL")
}
if !isValidAuthorityURL(normalized.Authority) {
return mastersettings.MicrosoftEntraConfigInput{}, errors.New("MS_ENTRA_AUTHORITY must be a valid absolute URL with scheme")
}
normalized.Authority = ensureTrailingSlash(normalized.Authority)
return normalized, nil
}
func normalizeCSV(raw string) string {
parts := strings.Split(raw, ",")
items := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
items = append(items, item)
}
return strings.Join(items, ",")
}
func isValidHTTPURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
if u == nil || !u.IsAbs() || strings.TrimSpace(u.Host) == "" {
return false
}
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
case "http", "https":
return true
default:
return false
}
}
func isValidAuthorityURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
return u != nil && u.IsAbs() && strings.TrimSpace(u.Host) != "" && strings.TrimSpace(u.Scheme) != ""
}
func ensureTrailingSlash(raw string) string {
if strings.HasSuffix(raw, "/") {
return raw
}
return raw + "/"
}
func maskMicrosoftSecret(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return ""
}
if len(secret) <= 4 {
return "****"
}
return strings.Repeat("*", len(secret)-4) + secret[len(secret)-4:]
}
func extractMicrosoftEntraValues(rows []mastersettings.MasterSettingValue) (tenant, clientID, secret, redirectURL, authority, scopes string, encrypted bool) {
if len(rows) == 0 {
return "", "", "", "", "", "", false
}
values := make(map[string]mastersettings.MasterSettingValue, len(rows))
for i := range rows {
values[rows[i].SettingKey] = rows[i]
}
tenant = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraTenantID].Value)
clientID = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraClientID].Value)
secret = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraClientSecret].Value)
redirectURL = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraRedirectURL].Value)
authority = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraAuthority].Value)
scopes = strings.TrimSpace(values[mastersettings.SettingMicrosoftEntraScopes].Value)
encrypted = values[mastersettings.SettingMicrosoftEntraClientSecret].IsEncrypted
return tenant, clientID, secret, redirectURL, authority, scopes, encrypted
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
items := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item == "" {
continue
}
items = append(items, item)
}
return items
}

View File

@@ -0,0 +1,434 @@
package service
import (
"context"
"encoding/base64"
"errors"
"testing"
"wucher/internal/config"
mastersettings "wucher/internal/domain/master_settings"
)
type masterSettingsRepoMock struct {
createErr error
updateErr error
deleteErr error
getErr error
listErr error
lastCreate *mastersettings.MasterSettings
lastUpdate *mastersettings.MasterSettings
lastDeleteID []byte
lastDeleteBy []byte
lastGetID []byte
lastListSort string
lastListFilter string
lastListLimit int
lastListOffset int
getRow *mastersettings.MasterSettings
listRows []mastersettings.MasterSettings
listTotal int64
lastSettingRows []mastersettings.MasterSettingValue
lastSettingKeys []string
settingRows []mastersettings.MasterSettingValue
settingErr error
}
func (m *masterSettingsRepoMock) Create(_ context.Context, row *mastersettings.MasterSettings) error {
m.lastCreate = row
return m.createErr
}
func (m *masterSettingsRepoMock) Update(_ context.Context, row *mastersettings.MasterSettings) error {
m.lastUpdate = row
return m.updateErr
}
func (m *masterSettingsRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.lastDeleteID = append([]byte(nil), id...)
m.lastDeleteBy = append([]byte(nil), deletedBy...)
return m.deleteErr
}
func (m *masterSettingsRepoMock) GetByID(_ context.Context, id []byte) (*mastersettings.MasterSettings, error) {
m.lastGetID = append([]byte(nil), id...)
return m.getRow, m.getErr
}
func (m *masterSettingsRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]mastersettings.MasterSettings, int64, error) {
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listRows, m.listTotal, m.listErr
}
func (m *masterSettingsRepoMock) UpsertSettingValues(_ context.Context, rows []mastersettings.MasterSettingValue) error {
m.lastSettingRows = append([]mastersettings.MasterSettingValue(nil), rows...)
return m.settingErr
}
func (m *masterSettingsRepoMock) GetSettingValuesByKeys(_ context.Context, keys []string) ([]mastersettings.MasterSettingValue, error) {
m.lastSettingKeys = append([]string(nil), keys...)
return append([]mastersettings.MasterSettingValue(nil), m.settingRows...), m.settingErr
}
func TestNewMasterSettingsService(t *testing.T) {
svc := NewMasterSettingsService(&masterSettingsRepoMock{}, MasterSettingsServiceDependencies{})
if svc == nil || svc.repo == nil {
t.Fatalf("expected service initialized")
}
}
func TestMasterSettingsServiceCreateUpdateDeleteGetByID(t *testing.T) {
ctx := context.Background()
row := &mastersettings.MasterSettings{CompanyName: "Wucher"}
id := []byte("1234567890123456")
actor := []byte("abcdefghijklmnop")
t.Run("create success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
if err := svc.Create(ctx, row); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if repo.lastCreate != row {
t.Fatalf("expected row passed to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(ctx, row); err == nil {
t.Fatalf("expected create error")
}
})
t.Run("update success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
if err := svc.Update(ctx, row); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if repo.lastUpdate != row {
t.Fatalf("expected row passed to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(ctx, row); err == nil {
t.Fatalf("expected update error")
}
})
t.Run("delete success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
if err := svc.Delete(ctx, id, actor); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(actor) {
t.Fatalf("expected id and deletedBy passed to repo")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(ctx, id, actor); err == nil {
t.Fatalf("expected delete error")
}
})
t.Run("get by id success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{getRow: row}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
got, err := svc.GetByID(ctx, id)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != row {
t.Fatalf("expected repo row returned")
}
if string(repo.lastGetID) != string(id) {
t.Fatalf("expected id passed to repo")
}
repo.getErr = errors.New("get failed")
if _, err := svc.GetByID(ctx, id); err == nil {
t.Fatalf("expected get error")
}
})
}
func TestMasterSettingsServiceList(t *testing.T) {
ctx := context.Background()
repo := &masterSettingsRepoMock{
listRows: []mastersettings.MasterSettings{{CompanyName: "Wucher"}},
listTotal: 1,
}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
rows, total, err := svc.List(ctx, "wu", "-updated_at", 10, 5)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result: len=%d total=%d", len(rows), total)
}
if repo.lastListSort != "updated_at DESC" {
t.Fatalf("expected normalized sort, got %q", repo.lastListSort)
}
if repo.lastListFilter != "wu" || repo.lastListLimit != 10 || repo.lastListOffset != 5 {
t.Fatalf("unexpected list params passed to repo")
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(ctx, "", "unknown", 0, 0); err == nil {
t.Fatalf("expected list error")
}
if repo.lastListSort != "" {
t.Fatalf("expected unknown sort normalized to empty string, got %q", repo.lastListSort)
}
}
func TestNormalizeMasterSettingsSort(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "company asc", in: "company_name", want: "company_name ASC"},
{name: "company desc", in: "-company_name", want: "company_name DESC"},
{name: "title asc", in: "title", want: "title ASC"},
{name: "title desc", in: "-title", want: "title DESC"},
{name: "subtitle asc", in: "subtitle", want: "subtitle ASC"},
{name: "subtitle desc", in: "-subtitle", want: "subtitle DESC"},
{name: "logo url asc", in: "logo_url", want: "logo_url ASC"},
{name: "logo url desc", in: "-logo_url", want: "logo_url DESC"},
{name: "logo file asc", in: "logo_file_uuid", want: "logo_file_id ASC"},
{name: "logo file desc", in: "-logo_file_uuid", want: "logo_file_id DESC"},
{name: "created asc", in: "created_at", want: "created_at ASC"},
{name: "created desc", in: "-created_at", want: "created_at DESC"},
{name: "updated asc", in: "updated_at", want: "updated_at ASC"},
{name: "updated desc", in: "-updated_at", want: "updated_at DESC"},
{name: "unknown", in: "x", want: ""},
{name: "empty", in: "", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := normalizeMasterSettingsSort(tc.in)
if got != tc.want {
t.Fatalf("normalize sort %q: got %q want %q", tc.in, got, tc.want)
}
})
}
}
func TestMasterSettingsServiceMicrosoftEntra(t *testing.T) {
ctx := context.Background()
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
keyB64 := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
protector, err := NewAESGCMProtectorFromBase64(keyB64)
if err != nil {
t.Fatalf("new protector: %v", err)
}
svc.SetSecretProtector(protector)
repo.settingRows = []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: "tenant"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: "client"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: "cipher", IsEncrypted: true},
{SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: "https://app.example.com/callback"},
{SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: "https://login.microsoftonline.com/"},
{SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: "openid,profile,email"},
}
got, err := svc.UpsertMicrosoftEntraConfig(ctx, mastersettings.MicrosoftEntraConfigInput{
TenantID: " tenant ",
ClientID: " client ",
ClientSecret: "super-secret",
RedirectURL: "https://app.example.com/callback",
Authority: "https://login.microsoftonline.com",
Scopes: "openid, profile,email,profile",
}, []byte("abcdefghijklmnop"))
if err != nil {
t.Fatalf("upsert microsoft entra config: %v", err)
}
if got == nil || got.ClientSecretMasked != "********" {
t.Fatalf("expected masked secret in view")
}
if len(repo.lastSettingRows) != len(mastersettings.MicrosoftEntraSettingKeys) {
t.Fatalf("unexpected setting rows len: %d", len(repo.lastSettingRows))
}
var encryptedSecret string
for _, row := range repo.lastSettingRows {
if row.SettingKey == mastersettings.SettingMicrosoftEntraClientSecret {
encryptedSecret = row.Value
if !row.IsEncrypted {
t.Fatalf("expected secret row encrypted")
}
}
}
if encryptedSecret == "" {
t.Fatalf("secret row not written")
}
rawCipher, err := base64.StdEncoding.DecodeString(encryptedSecret)
if err != nil {
t.Fatalf("decode secret ciphertext: %v", err)
}
plain, err := protector.Decrypt(rawCipher)
if err != nil {
t.Fatalf("decrypt secret ciphertext: %v", err)
}
if string(plain) != "super-secret" {
t.Fatalf("unexpected decrypted secret: %q", string(plain))
}
loaded, err := svc.GetMicrosoftEntraConfig(ctx)
if err != nil {
t.Fatalf("get microsoft entra config: %v", err)
}
if loaded == nil || loaded.ClientSecretMasked != "********" {
t.Fatalf("expected masked secret from get")
}
t.Run("keep existing secret when input secret is empty", func(t *testing.T) {
repo.lastSettingRows = nil
repo.settingRows = []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: "existing-cipher", IsEncrypted: true},
{SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: "tenant"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: "client"},
{SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: "https://app.example.com/callback"},
{SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: "https://login.microsoftonline.com/"},
{SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: "openid,profile,email"},
}
_, err := svc.UpsertMicrosoftEntraConfig(ctx, mastersettings.MicrosoftEntraConfigInput{
TenantID: "tenant",
ClientID: "client",
ClientSecret: " ",
RedirectURL: "https://app.example.com/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: "openid,profile,email",
}, []byte("abcdefghijklmnop"))
if err != nil {
t.Fatalf("upsert with empty secret: %v", err)
}
for _, row := range repo.lastSettingRows {
if row.SettingKey == mastersettings.SettingMicrosoftEntraClientSecret {
t.Fatalf("expected secret row not updated when input secret is empty")
}
}
})
t.Run("empty secret rejected on initial setup", func(t *testing.T) {
repo.lastSettingRows = nil
repo.settingRows = []mastersettings.MasterSettingValue{}
_, err := svc.UpsertMicrosoftEntraConfig(ctx, mastersettings.MicrosoftEntraConfigInput{
TenantID: "tenant",
ClientID: "client",
ClientSecret: "",
RedirectURL: "https://app.example.com/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: "openid,profile,email",
}, []byte("abcdefghijklmnop"))
if err == nil {
t.Fatalf("expected error when secret empty and no existing value")
}
if err.Error() != "MS_ENTRA_CLIENT_SECRET is required for initial setup" {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestSeedMicrosoftEntraConfigIfMissing(t *testing.T) {
ctx := context.Background()
keyB64 := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
protector, err := NewAESGCMProtectorFromBase64(keyB64)
if err != nil {
t.Fatalf("new protector: %v", err)
}
t.Run("seed inserts all keys when missing", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{Protector: protector})
err := svc.SeedMicrosoftEntraConfigIfMissing(ctx, config.MicrosoftSSOConfig{
TenantID: "tenant",
ClientID: "client",
ClientSecret: "secret",
RedirectURL: "https://app.example.com/challenge/msentra",
Authority: "https://login.microsoftonline.com/",
Scopes: []string{"openid", "profile", "email"},
})
if err != nil {
t.Fatalf("seed microsoft entra: %v", err)
}
if len(repo.lastSettingRows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(repo.lastSettingRows))
}
})
t.Run("seed only inserts missing and preserves existing", func(t *testing.T) {
repo := &masterSettingsRepoMock{
settingRows: []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: "existing-tenant"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: "existing-client"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: "existing-encrypted", IsEncrypted: true},
},
}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{Protector: protector})
err := svc.SeedMicrosoftEntraConfigIfMissing(ctx, config.MicrosoftSSOConfig{
TenantID: "tenant",
ClientID: "client",
ClientSecret: "secret",
RedirectURL: "https://app.example.com/challenge/msentra",
Authority: "https://login.microsoftonline.com",
Scopes: []string{"openid", "profile", "email"},
})
if err != nil {
t.Fatalf("seed microsoft entra: %v", err)
}
if len(repo.lastSettingRows) != 3 {
t.Fatalf("expected 3 missing rows to be inserted, got %d", len(repo.lastSettingRows))
}
for i := range repo.lastSettingRows {
if repo.lastSettingRows[i].SettingKey == mastersettings.SettingMicrosoftEntraClientSecret {
t.Fatalf("client secret should not be overwritten when existing value is present")
}
}
})
}
func TestMicrosoftEntraPartialViewButStrictRuntime(t *testing.T) {
ctx := context.Background()
repo := &masterSettingsRepoMock{
settingRows: []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: "https://app.example.com/challenge/msentra"},
{SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: "https://login.microsoftonline.com/"},
{SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: "openid,profile,email"},
},
}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
view, err := svc.GetMicrosoftEntraConfig(ctx)
if err != nil {
t.Fatalf("get microsoft entra config: %v", err)
}
if view == nil {
t.Fatalf("expected partial config view, got nil")
}
if view.RedirectURL == "" || view.Authority == "" || view.Scopes == "" {
t.Fatalf("expected non-sensitive fields populated")
}
if _, err := svc.LoadMicrosoftEntraRuntimeConfig(ctx); err == nil {
t.Fatalf("expected strict runtime config to fail when tenant/client/secret missing")
}
}

View File

@@ -0,0 +1,47 @@
package service
import (
"context"
"wucher/internal/domain/mcf"
)
type MCFService struct{ repo mcf.Repository }
func NewMCFService(repo mcf.Repository) *MCFService { return &MCFService{repo: repo} }
func (s *MCFService) Create(ctx context.Context, row *mcf.MaintenanceCheckFlight) error {
return s.repo.Create(ctx, row)
}
func (s *MCFService) Update(ctx context.Context, row *mcf.MaintenanceCheckFlight) error {
return s.repo.Update(ctx, row)
}
func (s *MCFService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *MCFService) GetByID(ctx context.Context, id []byte) (*mcf.MaintenanceCheckFlight, error) {
return s.repo.GetByID(ctx, id)
}
func (s *MCFService) ListByHelicopter(ctx context.Context, helicopterID []byte) ([]mcf.MaintenanceCheckFlight, error) {
return s.repo.ListByHelicopter(ctx, helicopterID)
}
func (s *MCFService) GetLatestByHelicopter(ctx context.Context, helicopterID []byte) (*mcf.MaintenanceCheckFlight, error) {
return s.repo.GetLatestByHelicopter(ctx, helicopterID)
}
func (s *MCFService) LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*mcf.MaintenanceCheckFlight, error) {
return s.repo.LatestByHelicopterIDs(ctx, helicopterIDs)
}
func (s *MCFService) PendingHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]bool, error) {
return s.repo.PendingHelicopterIDs(ctx, helicopterIDs)
}
func (s *MCFService) HelicopterHasOpenMCF(ctx context.Context, helicopterID []byte) (bool, error) {
return s.repo.HelicopterHasOpenMCF(ctx, helicopterID)
}

View File

@@ -0,0 +1,85 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/mcf"
)
type stubMCFRepo struct {
row *mcf.MaintenanceCheckFlight
pending map[string]bool
createErr error
created *mcf.MaintenanceCheckFlight
updated *mcf.MaintenanceCheckFlight
deletedID []byte
}
func (s *stubMCFRepo) Create(_ context.Context, row *mcf.MaintenanceCheckFlight) error {
s.created = row
return s.createErr
}
func (s *stubMCFRepo) Update(_ context.Context, row *mcf.MaintenanceCheckFlight) error {
s.updated = row
return nil
}
func (s *stubMCFRepo) Delete(_ context.Context, id, _ []byte) error { s.deletedID = id; return nil }
func (s *stubMCFRepo) GetByID(_ context.Context, _ []byte) (*mcf.MaintenanceCheckFlight, error) {
return s.row, nil
}
func (s *stubMCFRepo) ListByHelicopter(_ context.Context, _ []byte) ([]mcf.MaintenanceCheckFlight, error) {
if s.row != nil {
return []mcf.MaintenanceCheckFlight{*s.row}, nil
}
return nil, nil
}
func (s *stubMCFRepo) GetLatestByHelicopter(_ context.Context, _ []byte) (*mcf.MaintenanceCheckFlight, error) {
return s.row, nil
}
func (s *stubMCFRepo) PendingHelicopterIDs(_ context.Context, _ [][]byte) (map[string]bool, error) {
return s.pending, nil
}
func (s *stubMCFRepo) LatestByHelicopterIDs(_ context.Context, _ [][]byte) (map[string]*mcf.MaintenanceCheckFlight, error) {
return nil, nil
}
func (s *stubMCFRepo) HelicopterHasOpenMCF(_ context.Context, _ []byte) (bool, error) {
return false, nil
}
func TestMCFServiceDelegation(t *testing.T) {
repo := &stubMCFRepo{row: &mcf.MaintenanceCheckFlight{}, pending: map[string]bool{"x": true}}
svc := NewMCFService(repo)
ctx := context.Background()
row := &mcf.MaintenanceCheckFlight{}
if err := svc.Create(ctx, row); err != nil || repo.created != row {
t.Fatalf("Create not delegated: %v", err)
}
if err := svc.Update(ctx, row); err != nil || repo.updated != row {
t.Fatalf("Update not delegated: %v", err)
}
if err := svc.Delete(ctx, []byte("0123456789abcdef"), nil); err != nil || string(repo.deletedID) != "0123456789abcdef" {
t.Fatalf("Delete not delegated: %v", err)
}
if got, err := svc.GetByID(ctx, []byte("x")); err != nil || got != repo.row {
t.Fatalf("GetByID not delegated: %v", err)
}
if rows, err := svc.ListByHelicopter(ctx, []byte("h")); err != nil || len(rows) != 1 {
t.Fatalf("ListByHelicopter not delegated: %v", err)
}
if got, err := svc.PendingHelicopterIDs(ctx, [][]byte{[]byte("x")}); err != nil || !got["x"] {
t.Fatalf("PendingHelicopterIDs not delegated: %v", err)
}
if got, err := svc.GetLatestByHelicopter(ctx, []byte("h")); err != nil || got != repo.row {
t.Fatalf("GetLatestByHelicopter not delegated: %v", err)
}
repo.createErr = errors.New("boom")
if err := svc.Create(ctx, row); err == nil {
t.Fatal("expected create error to propagate")
}
}

View File

@@ -0,0 +1,176 @@
package service
import (
"context"
"errors"
"wucher/internal/domain/medicine"
"wucher/internal/shared/pkg/sortkey"
)
type MedicineService struct {
repo medicine.Repository
}
func NewMedicineService(repo medicine.Repository) *MedicineService {
return &MedicineService{repo: repo}
}
func (s *MedicineService) Create(ctx context.Context, row *medicine.Medicine) error {
return s.repo.Create(ctx, row)
}
func (s *MedicineService) Update(ctx context.Context, row *medicine.Medicine) error {
return s.repo.Update(ctx, row)
}
func (s *MedicineService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *MedicineService) GetByID(ctx context.Context, id []byte) (*medicine.Medicine, error) {
return s.repo.GetByID(ctx, id)
}
func (s *MedicineService) List(ctx context.Context, filter, sort string, limit, offset int) ([]medicine.Medicine, int64, error) {
return s.repo.List(ctx, filter, normalizeMedicineSort(sort), limit, offset)
}
func normalizeMedicineSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("name"),
sortExpr(
"COALESCE(NULLIF(note, ''), pack) ASC",
"COALESCE(NULLIF(note, ''), pack) DESC",
"note",
),
sortField("pack"),
sortField("unit"),
sortField("`column`", "column"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}
func (s *MedicineService) CreateGroup(ctx context.Context, row *medicine.MedicineGroup) error {
repo, ok := s.repo.(interface {
CreateGroup(context.Context, *medicine.MedicineGroup) error
})
if !ok {
return errors.New("medicine group repository not configured")
}
return repo.CreateGroup(ctx, row)
}
func (s *MedicineService) UpdateGroup(ctx context.Context, row *medicine.MedicineGroup) error {
repo, ok := s.repo.(interface {
UpdateGroup(context.Context, *medicine.MedicineGroup) error
})
if !ok {
return errors.New("medicine group repository not configured")
}
return repo.UpdateGroup(ctx, row)
}
func (s *MedicineService) DeleteGroup(ctx context.Context, id []byte, deletedBy []byte) error {
repo, ok := s.repo.(interface {
DeleteGroup(context.Context, []byte, []byte) error
})
if !ok {
return errors.New("medicine group repository not configured")
}
return repo.DeleteGroup(ctx, id, deletedBy)
}
func (s *MedicineService) GetGroupByID(ctx context.Context, id []byte) (*medicine.MedicineGroup, error) {
repo, ok := s.repo.(interface {
GetGroupByID(context.Context, []byte) (*medicine.MedicineGroup, error)
})
if !ok {
return nil, errors.New("medicine group repository not configured")
}
return repo.GetGroupByID(ctx, id)
}
func (s *MedicineService) ListGroups(ctx context.Context, filter, sort string, limit, offset int) ([]medicine.MedicineGroup, int64, error) {
repo, ok := s.repo.(interface {
ListGroups(context.Context, string, string, int, int) ([]medicine.MedicineGroup, int64, error)
})
if !ok {
return nil, 0, errors.New("medicine group repository not configured")
}
return repo.ListGroups(ctx, filter, normalizeMedicineGroupSort(sort), limit, offset)
}
func normalizeMedicineGroupSort(sort string) string {
return normalizeSortByRules(sort,
sortField("name"),
sortField("created_at"),
sortField("updated_at"),
)
}
func (s *MedicineService) CreateReaction(ctx context.Context, row *medicine.MotorReaction) error {
repo, ok := s.repo.(interface {
CreateReaction(context.Context, *medicine.MotorReaction) error
})
if !ok {
return errors.New("motor reaction repository not configured")
}
return repo.CreateReaction(ctx, row)
}
func (s *MedicineService) UpdateReaction(ctx context.Context, row *medicine.MotorReaction) error {
repo, ok := s.repo.(interface {
UpdateReaction(context.Context, *medicine.MotorReaction) error
})
if !ok {
return errors.New("motor reaction repository not configured")
}
return repo.UpdateReaction(ctx, row)
}
func (s *MedicineService) DeleteReaction(ctx context.Context, id []byte, deletedBy []byte) error {
repo, ok := s.repo.(interface {
DeleteReaction(context.Context, []byte, []byte) error
})
if !ok {
return errors.New("motor reaction repository not configured")
}
return repo.DeleteReaction(ctx, id, deletedBy)
}
func (s *MedicineService) GetReactionByID(ctx context.Context, id []byte) (*medicine.MotorReaction, error) {
repo, ok := s.repo.(interface {
GetReactionByID(context.Context, []byte) (*medicine.MotorReaction, error)
})
if !ok {
return nil, errors.New("motor reaction repository not configured")
}
return repo.GetReactionByID(ctx, id)
}
func (s *MedicineService) ListReactions(ctx context.Context, filter, sort string, limit, offset int) ([]medicine.MotorReaction, int64, error) {
repo, ok := s.repo.(interface {
ListReactions(context.Context, string, string, int, int) ([]medicine.MotorReaction, int64, error)
})
if !ok {
return nil, 0, errors.New("motor reaction repository not configured")
}
return repo.ListReactions(ctx, filter, normalizeMotorReactionSort(sort), limit, offset)
}
func normalizeMotorReactionSort(sort string) string {
return normalizeSortByRules(sort,
sortField("name"),
sortField("score"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,227 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/medicine"
)
type medicineRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *medicine.Medicine
lastUpdate *medicine.Medicine
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *medicine.Medicine
listResult []medicine.Medicine
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *medicineRepoMock) Create(_ context.Context, row *medicine.Medicine) error {
m.createCalled = true
m.lastCreate = row
return m.createErr
}
func (m *medicineRepoMock) Update(_ context.Context, row *medicine.Medicine) error {
m.updateCalled = true
m.lastUpdate = row
return m.updateErr
}
func (m *medicineRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *medicineRepoMock) GetByID(_ context.Context, id []byte) (*medicine.Medicine, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *medicineRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]medicine.Medicine, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewMedicineService(t *testing.T) {
repo := &medicineRepoMock{}
svc := NewMedicineService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestMedicineServiceCreate(t *testing.T) {
repo := &medicineRepoMock{}
svc := NewMedicineService(repo)
row := &medicine.Medicine{Name: "Paracetamol", Pack:"500mg", Column: 1}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestMedicineServiceUpdate(t *testing.T) {
repo := &medicineRepoMock{}
svc := NewMedicineService(repo)
row := &medicine.Medicine{Name: "Paracetamol", Pack:"500mg", Column: 1}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestMedicineServiceDelete(t *testing.T) {
repo := &medicineRepoMock{}
svc := NewMedicineService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestMedicineServiceGetByID(t *testing.T) {
expected := &medicine.Medicine{Name: "Paracetamol", Pack:"500mg", Column: 1}
repo := &medicineRepoMock{getByIDResult: expected}
svc := NewMedicineService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestMedicineServiceList(t *testing.T) {
repo := &medicineRepoMock{
listResult: []medicine.Medicine{{Name: "Paracetamol", Pack:"500mg", Column: 1}},
listTotal: 1,
}
svc := NewMedicineService(repo)
rows, total, err := svc.List(context.Background(), "find", "column", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "`column` ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "name", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "name ASC" {
t.Fatalf("unexpected name sort args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeMedicineSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"note": "COALESCE(NULLIF(note, ''), pack) ASC",
"-note": "COALESCE(NULLIF(note, ''), pack) DESC",
"pack": "pack ASC",
"-pack": "pack DESC",
"unit": "unit ASC",
"-unit": "unit DESC",
"column": "`column` ASC",
"-column": "`column` DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeMedicineSort(in); got != want {
t.Fatalf("normalizeMedicineSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,332 @@
package service
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"wucher/internal/config"
"wucher/internal/resilience"
)
type MicrosoftTokenSet struct {
AccessToken string
RefreshToken string
IDToken string
TokenType string
Scope string
ExpiresIn int64
RefreshTokenExpiresIn int64
}
type MicrosoftSSOClient struct {
cfg config.MicrosoftSSOConfig
client *http.Client
executor *resilience.Executor
configResolver func(context.Context) (config.MicrosoftSSOConfig, error)
}
func NewMicrosoftSSO(cfg config.MicrosoftSSOConfig) *MicrosoftSSOClient {
return &MicrosoftSSOClient{
cfg: cfg,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (m *MicrosoftSSOClient) WithExecutor(executor *resilience.Executor) *MicrosoftSSOClient {
if m == nil {
return nil
}
m.executor = executor
return m
}
func (m *MicrosoftSSOClient) WithConfigResolver(resolver func(context.Context) (config.MicrosoftSSOConfig, error)) *MicrosoftSSOClient {
if m == nil {
return nil
}
m.configResolver = resolver
return m
}
func (m *MicrosoftSSOClient) AuthCodeURL(state string) string {
cfg, err := m.currentConfig(context.Background())
if err != nil || !isMicrosoftSSOAuthConfigComplete(cfg) {
return ""
}
scopes := ensureMicrosoftScopes(cfg.Scopes)
q := url.Values{}
q.Set("client_id", cfg.ClientID)
q.Set("response_type", "code")
q.Set("redirect_uri", cfg.RedirectURL)
q.Set("response_mode", "query")
q.Set("scope", strings.Join(scopes, " "))
q.Set("state", state)
q.Set("prompt", "select_account")
return fmt.Sprintf("%s%s/oauth2/v2.0/authorize?%s", cfg.Authority, cfg.TenantID, q.Encode())
}
func (m *MicrosoftSSOClient) LogoutURL(postLogoutRedirectURL string) string {
cfg, err := m.currentConfig(context.Background())
if err != nil || !isMicrosoftSSOAuthConfigComplete(cfg) {
return ""
}
q := url.Values{}
if redirect := strings.TrimSpace(postLogoutRedirectURL); redirect != "" {
q.Set("post_logout_redirect_uri", redirect)
}
base := fmt.Sprintf("%s%s/oauth2/v2.0/logout", cfg.Authority, cfg.TenantID)
if encoded := q.Encode(); encoded != "" {
return base + "?" + encoded
}
return base
}
func (m *MicrosoftSSOClient) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) {
_, claims, err := m.ExchangeCodeAndTokens(ctx, code, "")
return claims, err
}
func (m *MicrosoftSSOClient) ExchangeCodeAndTokens(ctx context.Context, code, codeVerifier string) (MicrosoftTokenSet, MicrosoftClaims, error) {
set, err := resilience.Do(ctx, m.executor, func(ctx context.Context) (MicrosoftTokenSet, error) {
if code == "" {
return MicrosoftTokenSet{}, errors.New("missing code")
}
cfg, err := m.currentConfig(ctx)
if err != nil || !isMicrosoftSSOConfigComplete(cfg) {
return MicrosoftTokenSet{}, errors.New("sso not configured")
}
scopes := ensureMicrosoftScopes(cfg.Scopes)
form := url.Values{}
form.Set("client_id", cfg.ClientID)
form.Set("client_secret", cfg.ClientSecret)
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", cfg.RedirectURL)
form.Set("scope", strings.Join(scopes, " "))
if strings.TrimSpace(codeVerifier) != "" {
form.Set("code_verifier", strings.TrimSpace(codeVerifier))
}
tokenURL := fmt.Sprintf("%s%s/oauth2/v2.0/token", cfg.Authority, cfg.TenantID)
return m.exchangeTokenForm(ctx, tokenURL, form)
})
if err != nil {
return MicrosoftTokenSet{}, MicrosoftClaims{}, err
}
claims, err := parseMicrosoftClaimsFromIDToken(set.IDToken)
if err != nil {
return MicrosoftTokenSet{}, MicrosoftClaims{}, err
}
return set, claims, nil
}
func (m *MicrosoftSSOClient) RefreshAccessToken(ctx context.Context, refreshToken, codeVerifier string) (MicrosoftTokenSet, error) {
return resilience.Do(ctx, m.executor, func(ctx context.Context) (MicrosoftTokenSet, error) {
if strings.TrimSpace(refreshToken) == "" {
return MicrosoftTokenSet{}, errors.New("missing refresh token")
}
cfg, err := m.currentConfig(ctx)
if err != nil || !isMicrosoftSSOConfigComplete(cfg) {
return MicrosoftTokenSet{}, errors.New("sso not configured")
}
scopes := ensureMicrosoftScopes(cfg.Scopes)
form := url.Values{}
form.Set("client_id", cfg.ClientID)
form.Set("client_secret", cfg.ClientSecret)
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", strings.TrimSpace(refreshToken))
form.Set("redirect_uri", cfg.RedirectURL)
form.Set("scope", strings.Join(scopes, " "))
if strings.TrimSpace(codeVerifier) != "" {
form.Set("code_verifier", strings.TrimSpace(codeVerifier))
}
tokenURL := fmt.Sprintf("%s%s/oauth2/v2.0/token", cfg.Authority, cfg.TenantID)
return m.exchangeTokenForm(ctx, tokenURL, form)
})
}
func (m *MicrosoftSSOClient) exchangeTokenForm(ctx context.Context, tokenURL string, form url.Values) (MicrosoftTokenSet, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return MicrosoftTokenSet{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := m.client.Do(req)
if err != nil {
return MicrosoftTokenSet{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyStr := string(body)
if resp.StatusCode == http.StatusUnauthorized && strings.Contains(bodyStr, "invalid_client") {
return MicrosoftTokenSet{}, fmt.Errorf(
"token exchange failed: invalid_client (check MS_ENTRA_CLIENT_SECRET uses secret VALUE, not secret ID): %s",
bodyStr,
)
}
return MicrosoftTokenSet{}, &resilience.HTTPStatusError{
Operation: "token exchange failed",
StatusCode: resp.StatusCode,
Body: bodyStr,
}
}
var token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
ExpiresIn int64 `json:"expires_in"`
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
}
if err := json.Unmarshal(body, &token); err != nil {
return MicrosoftTokenSet{}, err
}
if strings.TrimSpace(token.IDToken) == "" {
return MicrosoftTokenSet{}, errors.New("missing id_token")
}
if strings.TrimSpace(token.AccessToken) == "" {
return MicrosoftTokenSet{}, errors.New("missing access_token")
}
return MicrosoftTokenSet{
AccessToken: strings.TrimSpace(token.AccessToken),
RefreshToken: strings.TrimSpace(token.RefreshToken),
IDToken: strings.TrimSpace(token.IDToken),
TokenType: strings.TrimSpace(token.TokenType),
Scope: strings.TrimSpace(token.Scope),
ExpiresIn: token.ExpiresIn,
RefreshTokenExpiresIn: token.RefreshTokenExpiresIn,
}, nil
}
func (m *MicrosoftSSOClient) currentConfig(ctx context.Context) (config.MicrosoftSSOConfig, error) {
if m == nil {
return config.MicrosoftSSOConfig{}, errors.New("sso client is nil")
}
if m.configResolver == nil {
return m.cfg, nil
}
return m.configResolver(ctx)
}
func isMicrosoftSSOConfigComplete(cfg config.MicrosoftSSOConfig) bool {
return strings.TrimSpace(cfg.TenantID) != "" &&
strings.TrimSpace(cfg.ClientID) != "" &&
strings.TrimSpace(cfg.ClientSecret) != "" &&
strings.TrimSpace(cfg.RedirectURL) != "" &&
strings.TrimSpace(cfg.Authority) != "" &&
len(ensureMicrosoftScopes(cfg.Scopes)) > 0
}
func IsMicrosoftSSOConfigComplete(cfg config.MicrosoftSSOConfig) bool {
return isMicrosoftSSOConfigComplete(cfg)
}
func isMicrosoftSSOAuthConfigComplete(cfg config.MicrosoftSSOConfig) bool {
return strings.TrimSpace(cfg.TenantID) != "" &&
strings.TrimSpace(cfg.ClientID) != "" &&
strings.TrimSpace(cfg.RedirectURL) != "" &&
strings.TrimSpace(cfg.Authority) != "" &&
len(ensureMicrosoftScopes(cfg.Scopes)) > 0
}
func ensureMicrosoftScopes(scopes []string) []string {
merged := make([]string, 0, len(scopes)+5)
seen := map[string]struct{}{}
appendScope := func(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
merged = append(merged, v)
}
for i := range scopes {
appendScope(scopes[i])
}
appendScope("openid")
appendScope("profile")
appendScope("email")
appendScope("offline_access")
appendScope("User.Read")
return merged
}
func parseMicrosoftClaimsFromIDToken(idToken string) (MicrosoftClaims, error) {
claimsMap, err := parseJWTClaims(idToken)
if err != nil {
return MicrosoftClaims{}, err
}
sub, _ := claimsMap["sub"].(string)
email, _ := claimsMap["email"].(string)
name, _ := claimsMap["name"].(string)
if email == "" {
email, _ = claimsMap["preferred_username"].(string)
}
tid, _ := claimsMap["tid"].(string)
oid, _ := claimsMap["oid"].(string)
sid, _ := claimsMap["sid"].(string)
return MicrosoftClaims{
Subject: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Name: strings.TrimSpace(name),
TenantID: strings.TrimSpace(tid),
ObjectID: strings.TrimSpace(oid),
SessionID: strings.TrimSpace(sid),
}, nil
}
func parseJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, errors.New("invalid jwt")
}
payload, err := decodeSegment(parts[1])
if err != nil {
return nil, err
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func decodeSegment(seg string) ([]byte, error) {
seg = strings.ReplaceAll(seg, "-", "+")
seg = strings.ReplaceAll(seg, "_", "/")
switch len(seg) % 4 {
case 2:
seg += "=="
case 3:
seg += "="
}
return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(seg)))
}
func microsoftPKCEChallengeS256(verifier string) string {
h := sha256.Sum256([]byte(strings.TrimSpace(verifier)))
return base64.RawURLEncoding.EncodeToString(h[:])
}

View File

@@ -0,0 +1,442 @@
package service
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"wucher/internal/config"
"wucher/internal/resilience"
)
func TestNewMicrosoftSSO(t *testing.T) {
cfg := config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: []string{"openid", "profile", "email"},
}
client := NewMicrosoftSSO(cfg)
if client == nil {
t.Fatalf("expected client")
}
if client.cfg.ClientID != cfg.ClientID {
t.Fatalf("client config not set")
}
if client.client == nil {
t.Fatalf("http client should not be nil")
}
if client.client.Timeout != 10*time.Second {
t.Fatalf("unexpected timeout: %v", client.client.Timeout)
}
}
func TestMicrosoftSSO_AuthCodeURL(t *testing.T) {
cfg := config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
RedirectURL: "http://localhost/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: []string{"openid", "profile", "email"},
}
client := NewMicrosoftSSO(cfg)
raw := client.AuthCodeURL("state-123")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
if parsed.Path != "/tenant-id/oauth2/v2.0/authorize" {
t.Fatalf("unexpected authorize path: %s", parsed.Path)
}
q := parsed.Query()
if q.Get("client_id") != "client-id" ||
q.Get("response_type") != "code" ||
q.Get("redirect_uri") != "http://localhost/callback" ||
q.Get("response_mode") != "query" ||
q.Get("scope") != "openid profile email offline_access User.Read" ||
q.Get("prompt") != "select_account" ||
q.Get("state") != "state-123" {
t.Fatalf("unexpected query values: %v", q)
}
if got := q.Get("max_age"); got != "" {
t.Fatalf("max_age should not be set, got %q", got)
}
}
func TestMicrosoftSSO_LogoutURL(t *testing.T) {
cfg := config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
RedirectURL: "http://localhost/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: []string{"openid", "profile", "email"},
}
client := NewMicrosoftSSO(cfg)
raw := client.LogoutURL("http://localhost/logout-done")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
if parsed.Path != "/tenant-id/oauth2/v2.0/logout" {
t.Fatalf("unexpected logout path: %s", parsed.Path)
}
if got := parsed.Query().Get("post_logout_redirect_uri"); got != "http://localhost/logout-done" {
t.Fatalf("unexpected post logout redirect: %q", got)
}
}
func TestMicrosoftSSO_ExchangeCode(t *testing.T) {
t.Run("missing code", func(t *testing.T) {
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{})
if _, err := client.ExchangeCode(context.Background(), ""); err == nil {
t.Fatalf("expected error for missing code")
}
})
t.Run("token endpoint non 2xx", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
})
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") {
t.Fatalf("expected token exchange failed error, got %v", err)
}
})
t.Run("invalid json response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{invalid`))
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
})
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil {
t.Fatalf("expected unmarshal error")
}
})
t.Run("missing id token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"abc"}`))
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
})
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "missing id_token") {
t.Fatalf("expected missing id_token error, got %v", err)
}
})
t.Run("invalid id token claims", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id_token":"invalid-token"}`))
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
})
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil {
t.Fatalf("expected parse jwt error")
}
})
t.Run("success with email claim", func(t *testing.T) {
idToken := makeUnsignedJWT(map[string]any{
"sub": "subject-1",
"email": "user@example.com",
"name": "John Doe",
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST")
}
if r.URL.Path != "/tenant-id/oauth2/v2.0/token" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if r.PostForm.Get("client_id") != "client-id" ||
r.PostForm.Get("client_secret") != "secret" ||
r.PostForm.Get("grant_type") != "authorization_code" ||
r.PostForm.Get("code") != "code-1" ||
r.PostForm.Get("redirect_uri") != "http://localhost/callback" ||
r.PostForm.Get("scope") != "openid profile email offline_access User.Read" {
t.Fatalf("unexpected form values: %v", r.PostForm)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken, "access_token": "at-1"})
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid", "profile", "email"},
})
claims, err := client.ExchangeCode(context.Background(), "code-1")
if err != nil {
t.Fatalf("exchange code failed: %v", err)
}
if claims.Subject != "subject-1" || claims.Email != "user@example.com" || claims.Name != "John Doe" {
t.Fatalf("unexpected claims: %+v", claims)
}
})
t.Run("success fallback preferred username", func(t *testing.T) {
idToken := makeUnsignedJWT(map[string]any{
"sub": "subject-2",
"preferred_username": "fallback@example.com",
"name": "Jane Doe",
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken, "access_token": "at-2"})
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
})
claims, err := client.ExchangeCode(context.Background(), "code-1")
if err != nil {
t.Fatalf("exchange code failed: %v", err)
}
if claims.Email != "fallback@example.com" {
t.Fatalf("expected preferred_username fallback, got %q", claims.Email)
}
})
t.Run("http client do error", func(t *testing.T) {
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: "https://example.com/",
Scopes: []string{"openid"},
})
client.client = &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("network down")
}),
}
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "network down") {
t.Fatalf("expected http client error, got %v", err)
}
})
}
func TestMicrosoftSSO_ExchangeCode_CircuitBreaker(t *testing.T) {
newExecutor := func() *resilience.Executor {
return resilience.NewExecutor("microsoft_sso", config.CircuitBreakerPolicy{
Enabled: true,
Timeout: time.Second,
MinRequests: 2,
FailureRatio: 0.5,
ConsecutiveFailures: 2,
}, slog.New(slog.NewJSONHandler(io.Discard, nil)), resilience.ClassifyMicrosoftSSOError)
}
t.Run("client errors are excluded", func(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
http.Error(w, "bad request", http.StatusBadRequest)
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
}).WithExecutor(newExecutor())
for range 3 {
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") {
t.Fatalf("expected token exchange failed error, got %v", err)
}
}
if hits != 3 {
t.Fatalf("expected breaker to stay closed for excluded client errors, got %d hits", hits)
}
})
t.Run("server errors open the breaker", func(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: "secret",
RedirectURL: "http://localhost/callback",
Authority: server.URL + "/",
Scopes: []string{"openid"},
}).WithExecutor(newExecutor())
for range 2 {
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") {
t.Fatalf("expected token exchange failed error, got %v", err)
}
}
_, err := client.ExchangeCode(context.Background(), "code-1")
var openErr *resilience.OpenError
if !errors.As(err, &openErr) {
t.Fatalf("expected circuit open error, got %v", err)
}
if hits != 2 {
t.Fatalf("expected breaker to fail fast after opening, got %d hits", hits)
}
})
}
func TestParseJWTClaims(t *testing.T) {
t.Run("invalid jwt format", func(t *testing.T) {
if _, err := parseJWTClaims("invalid"); err == nil {
t.Fatalf("expected invalid jwt error")
}
})
t.Run("invalid payload encoding", func(t *testing.T) {
if _, err := parseJWTClaims("header.%%%.sig"); err == nil {
t.Fatalf("expected decode error")
}
})
t.Run("invalid payload json", func(t *testing.T) {
payload := base64.RawURLEncoding.EncodeToString([]byte("not-json"))
if _, err := parseJWTClaims("header." + payload + ".sig"); err == nil {
t.Fatalf("expected json unmarshal error")
}
})
t.Run("success", func(t *testing.T) {
token := makeUnsignedJWT(map[string]any{
"sub": "subject-1",
"email": "user@example.com",
"name": "John Doe",
})
claims, err := parseJWTClaims(token)
if err != nil {
t.Fatalf("parse claims failed: %v", err)
}
if claims["sub"] != "subject-1" || claims["email"] != "user@example.com" {
t.Fatalf("unexpected claims: %+v", claims)
}
})
}
func TestDecodeSegment(t *testing.T) {
t.Run("success", func(t *testing.T) {
raw := base64.RawURLEncoding.EncodeToString([]byte("hello"))
got, err := decodeSegment(raw)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if string(got) != "hello" {
t.Fatalf("unexpected value: %q", string(got))
}
})
t.Run("success url safe alphabet", func(t *testing.T) {
got, err := decodeSegment("--__")
if err != nil {
t.Fatalf("decode failed: %v", err)
}
want := []byte{0xfb, 0xef, 0xff}
if len(got) != len(want) {
t.Fatalf("unexpected length: %d", len(got))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected byte at %d: got %x want %x", i, got[i], want[i])
}
}
})
t.Run("invalid base64", func(t *testing.T) {
if _, err := decodeSegment("%"); err == nil {
t.Fatalf("expected decode error")
}
})
}
type roundTripFunc func(req *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func makeUnsignedJWT(claims map[string]any) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payloadBytes, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
return header + "." + payload + ".sig"
}

View File

@@ -0,0 +1,356 @@
package service
import (
"context"
"errors"
"strconv"
"strings"
"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"
sharedconst "wucher/internal/shared/const"
"wucher/internal/shared/pkg/util"
)
type MissionService struct {
repo mission.Repository
flightSvc flightdomain.FlightService
flightDataSvc flightdata.Service
afterFlight afterflightinspection.Service
}
func missionCodePrefix(missionType string, t time.Time) string {
return strings.ToUpper(strings.TrimSpace(missionType)) + "-" + t.Format("06") + "-"
}
func NewMissionService(repo mission.Repository, deps ...any) *MissionService {
var flightSvc flightdomain.FlightService
var fdSvc flightdata.Service
var afterSvc afterflightinspection.Service
for _, dep := range deps {
switch v := dep.(type) {
case flightdomain.FlightService:
flightSvc = v
case flightdata.Service:
fdSvc = v
case afterflightinspection.Service:
afterSvc = v
}
}
return &MissionService{repo: repo, flightSvc: flightSvc, flightDataSvc: fdSvc, afterFlight: afterSvc}
}
func (s *MissionService) Create(ctx context.Context, row *mission.Mission) error {
if row == nil {
return mission.ErrRequired
}
if len(row.FlightID) == 0 {
return mission.ErrFlightIDRequired
}
row.Type = strings.ToUpper(strings.TrimSpace(row.Type))
category, err := s.repo.FindCategoryByCode(ctx, row.Type)
if err != nil {
return err
}
if category == nil || len(category.ID) == 0 {
return mission.ErrTypeInvalid
}
row.MissionCategoryID = append([]byte(nil), category.ID...)
if row.Type == sharedconst.MissionTypeHEMS {
if len(row.SubtypeID) == 0 {
return mission.ErrSubtypeRequired
}
ok, err := s.repo.SubCategoryBelongsToCategory(ctx, row.SubtypeID, row.MissionCategoryID)
if err != nil {
return err
}
if !ok {
return mission.ErrSubtypeInvalid
}
} else if len(row.SubtypeID) > 0 {
return mission.ErrSubtypeForbidden
}
startTime, endTime, err := s.resolveMissionTimes(ctx, row.FlightID)
if err != nil {
return err
}
row.StartTime = startTime
row.EndTime = endTime
if err := s.ensureNotAfterFlightLocked(ctx, row.FlightID); err != nil {
return err
}
return s.repo.WithTransaction(ctx, func(txCtx context.Context) error {
return s.persistMissionCreate(txCtx, row)
})
}
func (s *MissionService) persistMissionCreate(ctx context.Context, row *mission.Mission) error {
if strings.TrimSpace(row.Code) == "" {
prefix := missionCodePrefix(row.Type, time.Now())
maxSeq, err := s.repo.MaxCodeSeqByPrefix(ctx, prefix)
if err != nil {
return err
}
row.Code = prefix + strconv.Itoa(maxSeq+1)
}
return s.repo.Create(ctx, row)
}
func (s *MissionService) CreateType(ctx context.Context, row *mission.MissionCategory) error {
if row == nil {
return mission.ErrRequired
}
row.CodeType = strings.ToUpper(strings.TrimSpace(row.CodeType))
row.TypeName = strings.TrimSpace(row.TypeName)
if row.CodeType == "" || row.TypeName == "" {
return mission.ErrTypeInvalid
}
return s.repo.CreateCategory(ctx, row)
}
func (s *MissionService) UpdateByID(ctx context.Context, missionID []byte, missionType string, subtypeID []byte, note string, updatedBy []byte) error {
if len(missionID) == 0 {
return mission.ErrMissionIDRequired
}
missionType = strings.ToUpper(strings.TrimSpace(missionType))
category, err := s.repo.FindCategoryByCode(ctx, missionType)
if err != nil {
return err
}
if category == nil || len(category.ID) == 0 {
return mission.ErrTypeInvalid
}
if missionType == sharedconst.MissionTypeHEMS {
if len(subtypeID) == 0 {
return mission.ErrSubtypeRequired
}
ok, err := s.repo.SubCategoryBelongsToCategory(ctx, subtypeID, category.ID)
if err != nil {
return err
}
if !ok {
return mission.ErrSubtypeInvalid
}
} else if len(subtypeID) > 0 {
return mission.ErrSubtypeForbidden
}
return s.repo.UpdateByID(ctx, missionID, missionType, category.ID, subtypeID, note, updatedBy)
}
func (s *MissionService) FlightIDByMissionID(ctx context.Context, missionID []byte) ([]byte, error) {
if len(missionID) != 16 {
return nil, nil
}
row, err := s.repo.GetByID(ctx, missionID)
if err != nil {
return nil, err
}
if row == nil || len(row.FlightID) != 16 {
return nil, nil
}
return append([]byte(nil), row.FlightID...), nil
}
func (s *MissionService) resolveMissionTimes(ctx context.Context, flightID []byte) (string, string, error) {
if s.flightSvc == nil {
return "", "", mission.ErrScheduleMissing
}
flightRow, err := s.flightSvc.GetByID(ctx, flightID)
if err != nil {
return "", "", err
}
if flightRow == nil || len(flightRow.ID) != 16 || flightRow.Date.IsZero() {
return "", "", mission.ErrScheduleMissing
}
baseRow := missionBaseFromFlight(flightRow)
if baseRow == nil {
return "", "", mission.ErrBaseMissing
}
startTime, endTime, err := util.ResolveShiftWindow(baseRow, flightRow.Date)
if err != nil {
return "", "", mapBaseShiftError(err)
}
return startTime, endTime, nil
}
func mapBaseShiftError(err error) error {
switch {
case errors.Is(err, util.ErrShiftCoordinatesInvalid):
return mission.ErrCoordinatesInvalid
case errors.Is(err, util.ErrShiftTwilightMissing):
return mission.ErrTwilightMissing
case errors.Is(err, util.ErrShiftScheduleMissing):
return mission.ErrScheduleMissing
default:
return err
}
}
func (s *MissionService) ensureNotAfterFlightLocked(ctx context.Context, flightID []byte) error {
if s.flightSvc == nil || s.afterFlight == nil || len(flightID) != 16 {
return nil
}
flightRow, err := s.flightSvc.GetByID(ctx, flightID)
if err != nil {
return err
}
if flightRow == nil || flightRow.Takeover == nil || flightRow.Takeover.ReserveAc == nil || len(flightRow.Takeover.ReserveAc.InspectionID) != 16 {
return nil
}
afterRow, err := s.afterFlight.GetByFlightInspectionID(ctx, flightRow.Takeover.ReserveAc.InspectionID)
if err != nil {
return err
}
if afterRow != nil {
return mission.ErrLockedAfterFlightInspection
}
return nil
}
func missionBaseFromFlight(row *flightdomain.Flight) *basedomain.Base {
if row == nil || row.Takeover == nil {
return nil
}
return row.Takeover.Base
}
func (s *MissionService) DeleteByID(ctx context.Context, missionID []byte, deletedBy []byte) error {
if len(missionID) == 0 {
return mission.ErrMissionIDRequired
}
return s.repo.DeleteByID(ctx, missionID, deletedBy)
}
func (s *MissionService) DeleteByFlightID(ctx context.Context, flightID []byte, deletedBy []byte) error {
if len(flightID) == 0 {
return mission.ErrFlightIDRequired
}
return s.repo.DeleteByFlightID(ctx, flightID, deletedBy)
}
func (s *MissionService) AttachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error {
if len(missionID) != 16 {
return mission.ErrMissionIDRequired
}
if len(fileAttachmentID) != 16 {
return mission.ErrFileAttachmentIDRequired
}
return s.repo.AttachFile(ctx, missionID, fileAttachmentID)
}
func (s *MissionService) DetachFile(ctx context.Context, missionID []byte, fileAttachmentID []byte) error {
if len(missionID) != 16 {
return mission.ErrMissionIDRequired
}
if len(fileAttachmentID) != 16 {
return mission.ErrFileAttachmentIDRequired
}
return s.repo.DetachFile(ctx, missionID, fileAttachmentID)
}
func (s *MissionService) GetByID(ctx context.Context, missionID []byte) (*mission.Mission, error) {
if len(missionID) == 0 {
return nil, mission.ErrMissionIDRequired
}
row, err := s.repo.GetByID(ctx, missionID)
if err != nil {
return nil, err
}
if err := s.hydrateFlightDataCompletion(ctx, row); err != nil {
return nil, err
}
return row, nil
}
func (s *MissionService) ListByFlightID(ctx context.Context, flightID []byte) ([]mission.Mission, error) {
if len(flightID) == 0 {
return nil, mission.ErrFlightIDRequired
}
rows, err := s.repo.ListByFlightID(ctx, flightID)
if err != nil {
return nil, err
}
return rows, nil
}
func (s *MissionService) ListByFlightIDs(ctx context.Context, flightIDs [][]byte) ([]mission.Mission, error) {
rows, err := s.repo.ListByFlightIDs(ctx, flightIDs)
if err != nil {
return nil, err
}
return rows, nil
}
func (s *MissionService) GetByFlightID(ctx context.Context, flightID []byte) (*mission.Mission, error) {
if len(flightID) == 0 {
return nil, mission.ErrFlightIDRequired
}
row, err := s.repo.GetByFlightID(ctx, flightID)
if err != nil {
return nil, err
}
return row, nil
}
func (s *MissionService) List(ctx context.Context, filter, sort string, flightID []byte, flightDataStatus string, limit, offset int) ([]mission.Mission, int64, error) {
rows, total, err := s.repo.List(ctx, filter, normalizeMissionSort(sort), flightID, flightDataStatus, limit, offset)
if err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (s *MissionService) ListDatatable(ctx context.Context, filter mission.ListFilter, limit, offset int) ([]mission.Mission, int64, int64, error) {
filter.Sort = normalizeMissionSort(filter.Sort)
rows, total, filtered, err := s.repo.ListDatatable(ctx, filter, limit, offset)
if err != nil {
return nil, 0, 0, err
}
return rows, total, filtered, nil
}
func (s *MissionService) ListTypes(ctx context.Context) ([]mission.MissionCategory, error) {
return s.repo.ListCategoriesWithSubCategories(ctx)
}
func (s *MissionService) hydrateFlightDataCompletion(ctx context.Context, row *mission.Mission) error {
if s.flightDataSvc == nil || row == nil || len(row.ID) == 0 {
return nil
}
fd, err := s.flightDataSvc.GetByMissionID(ctx, row.ID)
if err != nil {
return err
}
if fd == nil {
return nil
}
var details *flightdata.SPODetails
if strings.ToUpper(strings.TrimSpace(row.Type)) == sharedconst.MissionTypeSPO {
details, err = s.flightDataSvc.GetSPODetailsByFlightDataID(ctx, fd.ID)
if err != nil {
return err
}
}
row.FlightDataStatus = flightdata.DeriveStatus(fd, details)
return nil
}
func normalizeMissionSort(sort string) string {
if normalized := normalizeSortByRules(sort,
sortField("missions.type", "type"),
sortField("flights.date", "flight_date"),
sortField("missions.created_at", "created_at"),
sortField("missions.updated_at", "updated_at"),
); normalized != "" {
return normalized
}
return "missions.created_at DESC"
}

View File

@@ -0,0 +1,581 @@
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")
}
}

View File

@@ -0,0 +1,141 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"wucher/internal/shared/pkg/uuidv7"
"wucher/internal/queue"
)
type MockEmailSender struct {
dir string
}
type MockEmailRecord struct {
StoredAt time.Time `json:"stored_at"`
Provider string `json:"provider"`
JobID string `json:"job_id"`
MessageType string `json:"message_type"`
To string `json:"to"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
ReplyTo []string `json:"reply_to,omitempty"`
Subject string `json:"subject"`
TextBody string `json:"text_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
TraceID string `json:"trace_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
func NewMockEmailSender(dir string) *MockEmailSender {
return &MockEmailSender{dir: strings.TrimSpace(dir)}
}
func (s *MockEmailSender) Configured() bool {
return s != nil && strings.TrimSpace(s.dir) != ""
}
func (s *MockEmailSender) Send(ctx context.Context, job queue.EmailJob) (EmailDeliveryResult, error) {
if !s.Configured() {
return EmailDeliveryResult{}, errors.New("mock email sender is not configured")
}
job = job.Canonical()
if err := job.Validate(); err != nil {
return EmailDeliveryResult{}, err
}
select {
case <-ctx.Done():
return EmailDeliveryResult{}, ctx.Err()
default:
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return EmailDeliveryResult{}, err
}
jobID := strings.TrimSpace(job.JobID)
if jobID == "" {
var err error
jobID, err = uuidv7.String(uuidv7.MustNew())
if err != nil {
return EmailDeliveryResult{}, err
}
}
filename := time.Now().UTC().Format("20060102T150405.000000000Z") + "_" + sanitizeFileName(job.NormalizedType()) + "_" + sanitizeFileName(jobID) + ".json"
path := filepath.Join(s.dir, filename)
record := MockEmailRecord{
StoredAt: time.Now().UTC(),
Provider: "mock",
JobID: jobID,
MessageType: job.NormalizedType(),
To: job.To,
CC: append([]string(nil), job.CC...),
BCC: append([]string(nil), job.BCC...),
ReplyTo: append([]string(nil), job.ReplyTo...),
Subject: job.Subject,
TextBody: job.TextBodyValue(),
HTMLBody: job.HTMLBody,
Tags: cloneStringMap(job.Tags),
Metadata: cloneStringMap(job.Metadata),
TraceID: job.TraceID,
RequestID: job.RequestID,
}
payload, err := json.MarshalIndent(record, "", " ")
if err != nil {
return EmailDeliveryResult{}, err
}
payload = append(payload, '\n')
if err := os.WriteFile(path, payload, 0o600); err != nil {
return EmailDeliveryResult{}, err
}
return EmailDeliveryResult{
Provider: "mock",
ProviderMessageID: filename,
RequestID: path,
}, nil
}
func sanitizeFileName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "email"
}
var b strings.Builder
b.Grow(len(value))
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return b.String()
}
func cloneStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,66 @@
package service
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"wucher/internal/queue"
)
func TestMockEmailSenderSend(t *testing.T) {
dir := t.TempDir()
sender := NewMockEmailSender(dir)
result, err := sender.Send(context.Background(), queue.EmailJob{
JobID: "job-123",
MessageType: "password_reset",
To: "user@example.com",
Subject: "Reset password",
TextBody: "plain body",
HTMLBody: "<p>html body</p>",
Tags: map[string]string{
"message_type": "password_reset",
},
})
if err != nil {
t.Fatalf("send: %v", err)
}
if result.Provider != "mock" || result.ProviderMessageID == "" || result.RequestID == "" {
t.Fatalf("unexpected result: %+v", result)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected one stored email, got %d", len(entries))
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("read file: %v", err)
}
var record MockEmailRecord
if err := json.Unmarshal(data, &record); err != nil {
t.Fatalf("unmarshal record: %v", err)
}
if record.JobID != "job-123" || record.MessageType != "password_reset" || record.To != "user@example.com" {
t.Fatalf("unexpected record: %+v", record)
}
if record.TextBody != "plain body" || record.HTMLBody != "<p>html body</p>" {
t.Fatalf("unexpected bodies: %+v", record)
}
}
func TestMockEmailSenderValidation(t *testing.T) {
if _, err := NewMockEmailSender("").Send(context.Background(), queue.EmailJob{
To: "user@example.com",
Subject: "Hello",
TextBody: "World",
}); err == nil {
t.Fatalf("expected config error")
}
}

View File

@@ -0,0 +1,51 @@
package service
import (
"context"
"wucher/internal/domain/opc"
"wucher/internal/shared/pkg/sortkey"
)
type OpcService struct {
repo opc.Repository
}
func NewOpcService(repo opc.Repository) *OpcService {
return &OpcService{repo: repo}
}
func (s *OpcService) Create(ctx context.Context, o *opc.Opc) error {
return s.repo.Create(ctx, o)
}
func (s *OpcService) Update(ctx context.Context, o *opc.Opc) error {
return s.repo.Update(ctx, o)
}
func (s *OpcService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *OpcService) GetByID(ctx context.Context, id []byte) (*opc.Opc, error) {
return s.repo.GetByID(ctx, id)
}
func (s *OpcService) List(ctx context.Context, filter, sort string, limit, offset int) ([]opc.Opc, int64, error) {
return s.repo.List(ctx, filter, normalizeOpcSort(sort), limit, offset)
}
func normalizeOpcSort(sort string) string {
return normalizeSortByRules(sort,
sortExpr(
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", false)),
joinSortClauses(sortkey.ActivePositiveSortClauses("", "is_active", "sortkey", "name", true)),
"sortkey",
),
sortField("name", "title"),
sortField("note"),
sortField("is_active"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,223 @@
package service
import (
"context"
"errors"
"testing"
"wucher/internal/domain/opc"
)
type opcRepoMock struct {
createCalled bool
updateCalled bool
deleteCalled bool
getByIDCalled bool
listCalled bool
lastCreate *opc.Opc
lastUpdate *opc.Opc
lastDeleteID []byte
lastDeleteBy []byte
lastGetByID []byte
lastListFilter string
lastListSort string
lastListLimit int
lastListOffset int
getByIDResult *opc.Opc
listResult []opc.Opc
listTotal int64
createErr error
updateErr error
deleteErr error
getByIDErr error
listErr error
}
func (m *opcRepoMock) Create(_ context.Context, o *opc.Opc) error {
m.createCalled = true
m.lastCreate = o
return m.createErr
}
func (m *opcRepoMock) Update(_ context.Context, o *opc.Opc) error {
m.updateCalled = true
m.lastUpdate = o
return m.updateErr
}
func (m *opcRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
return m.deleteErr
}
func (m *opcRepoMock) GetByID(_ context.Context, id []byte) (*opc.Opc, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *opcRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]opc.Opc, int64, error) {
m.listCalled = true
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listResult, m.listTotal, m.listErr
}
func TestNewOpcService(t *testing.T) {
repo := &opcRepoMock{}
svc := NewOpcService(repo)
if svc == nil || svc.repo == nil {
t.Fatalf("expected service and repo initialized")
}
}
func TestOpcServiceCreate(t *testing.T) {
repo := &opcRepoMock{}
svc := NewOpcService(repo)
row := &opc.Opc{Title: "Title"}
if err := svc.Create(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.createCalled || repo.lastCreate != row {
t.Fatalf("expected Create forwarded to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestOpcServiceUpdate(t *testing.T) {
repo := &opcRepoMock{}
svc := NewOpcService(repo)
row := &opc.Opc{Title: "Updated"}
if err := svc.Update(context.Background(), row); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.updateCalled || repo.lastUpdate != row {
t.Fatalf("expected Update forwarded to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(context.Background(), row); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestOpcServiceDelete(t *testing.T) {
repo := &opcRepoMock{}
svc := NewOpcService(repo)
id := []byte("id")
deletedBy := []byte("by")
if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !repo.deleteCalled {
t.Fatalf("expected Delete forwarded to repo")
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
t.Fatalf("expected delete params forwarded")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(context.Background(), id, deletedBy); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestOpcServiceGetByID(t *testing.T) {
expected := &opc.Opc{Title: "A"}
repo := &opcRepoMock{getByIDResult: expected}
svc := NewOpcService(repo)
id := []byte("id")
got, err := svc.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != expected {
t.Fatalf("unexpected result")
}
if !repo.getByIDCalled || string(repo.lastGetByID) != string(id) {
t.Fatalf("expected GetByID forwarded")
}
repo.getByIDErr = errors.New("get failed")
if _, err := svc.GetByID(context.Background(), id); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestOpcServiceList(t *testing.T) {
repo := &opcRepoMock{
listResult: []opc.Opc{{Title: "A"}},
listTotal: 1,
}
svc := NewOpcService(repo)
rows, total, err := svc.List(context.Background(), "find", "title", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if !repo.listCalled {
t.Fatalf("expected List forwarded to repo")
}
if repo.lastListFilter != "find" || repo.lastListSort != "name ASC" || repo.lastListLimit != 10 || repo.lastListOffset != 20 {
t.Fatalf("unexpected list args forwarded")
}
rows, total, err = svc.List(context.Background(), "find", "sortkey", 10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result")
}
if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC" {
t.Fatalf("unexpected sortkey args forwarded: %q", repo.lastListSort)
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(context.Background(), "", "unknown", 1, 0); err == nil {
t.Fatalf("expected error from repo")
}
}
func TestNormalizeOpcSort(t *testing.T) {
cases := map[string]string{
"title": "name ASC",
"-title": "name DESC",
"sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC, name ASC",
"-sortkey": "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, name ASC",
"note": "note ASC",
"-note": "note DESC",
"is_active": "is_active ASC",
"-is_active": "is_active DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"unknown": "",
"": "",
}
for in, want := range cases {
if got := normalizeOpcSort(in); got != want {
t.Fatalf("normalizeOpcSort(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,83 @@
package service
import (
"bytes"
"context"
"strings"
otherperson "wucher/internal/domain/other_person"
)
type OtherPersonService struct{ repo otherperson.Repository }
func NewOtherPersonService(repo otherperson.Repository) *OtherPersonService {
return &OtherPersonService{repo: repo}
}
func (s *OtherPersonService) Upsert(ctx context.Context, p *otherperson.OtherPerson) error {
return s.repo.Upsert(ctx, p)
}
// Create registers a new master person. Name is required and the (name, mobile_phone,
// email) identity must be unique among live rows.
func (s *OtherPersonService) Create(ctx context.Context, p *otherperson.OtherPerson) error {
p.Name = strings.TrimSpace(p.Name)
p.MobilePhone = strings.TrimSpace(p.MobilePhone)
p.Email = strings.TrimSpace(p.Email)
if p.Name == "" {
return otherperson.ErrNameRequired
}
existing, err := s.repo.FindByIdentity(ctx, p.Name, p.MobilePhone, p.Email)
if err != nil {
return err
}
if existing != nil {
return otherperson.ErrDuplicate
}
return s.repo.Create(ctx, p)
}
// Update replaces the identity fields of an existing master person. The new identity must
// not collide with a different live row.
func (s *OtherPersonService) Update(ctx context.Context, p *otherperson.OtherPerson) error {
p.Name = strings.TrimSpace(p.Name)
p.MobilePhone = strings.TrimSpace(p.MobilePhone)
p.Email = strings.TrimSpace(p.Email)
if p.Name == "" {
return otherperson.ErrNameRequired
}
current, err := s.repo.GetByID(ctx, p.ID)
if err != nil {
return err
}
if current == nil {
return otherperson.ErrNotFound
}
existing, err := s.repo.FindByIdentity(ctx, p.Name, p.MobilePhone, p.Email)
if err != nil {
return err
}
if existing != nil && !bytes.Equal(existing.ID, p.ID) {
return otherperson.ErrDuplicate
}
return s.repo.Update(ctx, p)
}
func (s *OtherPersonService) Delete(ctx context.Context, id, actor []byte) error {
current, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if current == nil {
return otherperson.ErrNotFound
}
return s.repo.Delete(ctx, id, actor)
}
func (s *OtherPersonService) GetByID(ctx context.Context, id []byte) (*otherperson.OtherPerson, error) {
return s.repo.GetByID(ctx, id)
}
func (s *OtherPersonService) Search(ctx context.Context, q string, limit, offset int) ([]otherperson.OtherPerson, int64, error) {
return s.repo.Search(ctx, q, limit, offset)
}

View File

@@ -0,0 +1,142 @@
package service
import (
"context"
"errors"
"testing"
otherperson "wucher/internal/domain/other_person"
"wucher/internal/shared/pkg/uuidv7"
)
type fakeOtherPersonRepo struct {
byID map[string]*otherperson.OtherPerson
identity map[string]*otherperson.OtherPerson // key: name|phone|email
created *otherperson.OtherPerson
updated *otherperson.OtherPerson
deletedID []byte
forceError error
}
func newFakeOtherPersonRepo() *fakeOtherPersonRepo {
return &fakeOtherPersonRepo{
byID: map[string]*otherperson.OtherPerson{},
identity: map[string]*otherperson.OtherPerson{},
}
}
func identityKey(name, phone, email string) string { return name + "|" + phone + "|" + email }
func (r *fakeOtherPersonRepo) Upsert(context.Context, *otherperson.OtherPerson) error { return nil }
func (r *fakeOtherPersonRepo) Create(_ context.Context, p *otherperson.OtherPerson) error {
if r.forceError != nil {
return r.forceError
}
if len(p.ID) == 0 {
p.ID = uuidv7.MustBytes()
}
r.created = p
cp := *p
r.byID[string(p.ID)] = &cp
r.identity[identityKey(p.Name, p.MobilePhone, p.Email)] = &cp
return nil
}
func (r *fakeOtherPersonRepo) Update(_ context.Context, p *otherperson.OtherPerson) error {
if r.forceError != nil {
return r.forceError
}
r.updated = p
cp := *p
r.byID[string(p.ID)] = &cp
return nil
}
func (r *fakeOtherPersonRepo) Delete(_ context.Context, id, _ []byte) error {
if r.forceError != nil {
return r.forceError
}
r.deletedID = id
delete(r.byID, string(id))
return nil
}
func (r *fakeOtherPersonRepo) GetByID(_ context.Context, id []byte) (*otherperson.OtherPerson, error) {
return r.byID[string(id)], nil
}
func (r *fakeOtherPersonRepo) FindByIdentity(_ context.Context, name, phone, email string) (*otherperson.OtherPerson, error) {
return r.identity[identityKey(name, phone, email)], nil
}
func (r *fakeOtherPersonRepo) Search(context.Context, string, int, int) ([]otherperson.OtherPerson, int64, error) {
return nil, 0, nil
}
func TestOtherPersonServiceCreate(t *testing.T) {
repo := newFakeOtherPersonRepo()
svc := NewOtherPersonService(repo)
ctx := context.Background()
// Name required.
if err := svc.Create(ctx, &otherperson.OtherPerson{Name: " "}); !errors.Is(err, otherperson.ErrNameRequired) {
t.Fatalf("expected ErrNameRequired, got %v", err)
}
// Happy path.
p := &otherperson.OtherPerson{Name: "Guest", MobilePhone: "1", Email: "g@x.io"}
if err := svc.Create(ctx, p); err != nil {
t.Fatalf("create: %v", err)
}
if len(p.ID) == 0 || repo.created == nil {
t.Fatalf("expected create persisted")
}
// Duplicate identity rejected.
dup := &otherperson.OtherPerson{Name: "Guest", MobilePhone: "1", Email: "g@x.io"}
if err := svc.Create(ctx, dup); !errors.Is(err, otherperson.ErrDuplicate) {
t.Fatalf("expected ErrDuplicate, got %v", err)
}
}
func TestOtherPersonServiceUpdate(t *testing.T) {
repo := newFakeOtherPersonRepo()
svc := NewOtherPersonService(repo)
ctx := context.Background()
// Seed two rows.
a := &otherperson.OtherPerson{Name: "Alpha", MobilePhone: "1", Email: "a@x.io"}
b := &otherperson.OtherPerson{Name: "Beta", MobilePhone: "2", Email: "b@x.io"}
_ = svc.Create(ctx, a)
_ = svc.Create(ctx, b)
// Updating a missing id -> not found.
missing := &otherperson.OtherPerson{ID: uuidv7.MustBytes(), Name: "X"}
if err := svc.Update(ctx, missing); !errors.Is(err, otherperson.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
// Updating A to B's identity -> duplicate.
clash := &otherperson.OtherPerson{ID: a.ID, Name: "Beta", MobilePhone: "2", Email: "b@x.io"}
if err := svc.Update(ctx, clash); !errors.Is(err, otherperson.ErrDuplicate) {
t.Fatalf("expected ErrDuplicate, got %v", err)
}
// Updating A to a fresh identity -> ok.
ok := &otherperson.OtherPerson{ID: a.ID, Name: "Alpha2", MobilePhone: "1", Email: "a@x.io"}
if err := svc.Update(ctx, ok); err != nil {
t.Fatalf("expected update ok, got %v", err)
}
if repo.updated == nil || repo.updated.Name != "Alpha2" {
t.Fatalf("update not forwarded")
}
}
func TestOtherPersonServiceDeleteNotFound(t *testing.T) {
repo := newFakeOtherPersonRepo()
svc := NewOtherPersonService(repo)
if err := svc.Delete(context.Background(), uuidv7.MustBytes(), nil); !errors.Is(err, otherperson.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}

View File

@@ -0,0 +1,46 @@
package service
import (
"context"
patientdata "wucher/internal/domain/patient_data"
)
type PatientDataService struct {
repo patientdata.Repository
}
func NewPatientDataService(repo patientdata.Repository) *PatientDataService {
return &PatientDataService{repo: repo}
}
func (s *PatientDataService) Create(ctx context.Context, row *patientdata.PatientData) error {
return s.repo.Create(ctx, row)
}
func (s *PatientDataService) Update(ctx context.Context, row *patientdata.PatientData) error {
return s.repo.Update(ctx, row)
}
func (s *PatientDataService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *PatientDataService) GetByID(ctx context.Context, id []byte) (*patientdata.PatientData, error) {
return s.repo.GetByID(ctx, id)
}
func (s *PatientDataService) List(ctx context.Context, filter, sort string, limit, offset int) ([]patientdata.PatientData, int64, error) {
return s.repo.List(ctx, filter, normalizePatientDataSort(sort), limit, offset)
}
func normalizePatientDataSort(sort string) string {
return normalizeSortByRules(sort,
sortField("family_name"),
sortField("first_name"),
sortField("birth_date"),
sortField("gender"),
sortField("created_at"),
sortField("updated_at"),
)
}

View File

@@ -0,0 +1,82 @@
package service
import (
"context"
"strings"
reserveac "wucher/internal/domain/reserve_ac"
"wucher/internal/shared/pkg/reserveacutil"
shareddto "wucher/internal/transport/http/dto/shared"
)
type ReserveAcService struct {
repo reserveac.ReserveAcRepository
}
func NewReserveAcService(repo reserveac.ReserveAcRepository) *ReserveAcService {
return &ReserveAcService{repo: repo}
}
func (s *ReserveAcService) Create(ctx context.Context, row *reserveac.ReserveAc) error {
row.Status = reserveacutil.NormalizeStatus(row.Status)
return s.repo.Create(ctx, row)
}
func (s *ReserveAcService) Update(ctx context.Context, row *reserveac.ReserveAc) error {
row.Status = reserveacutil.NormalizeStatus(row.Status)
return s.repo.Update(ctx, row)
}
func (s *ReserveAcService) Delete(ctx context.Context, id []byte, deletedBy []byte) error {
return s.repo.Delete(ctx, id, deletedBy)
}
func (s *ReserveAcService) GetByID(ctx context.Context, id []byte) (*reserveac.ReserveAc, error) {
return s.repo.GetByID(ctx, id)
}
func (s *ReserveAcService) GetActiveByInspectionID(ctx context.Context, inspectionID []byte) (*reserveac.ReserveAc, error) {
return s.repo.GetActiveByInspectionID(ctx, inspectionID)
}
func (s *ReserveAcService) List(ctx context.Context, filter string, sort string, limit, offset int) ([]reserveac.ReserveAc, int64, error) {
return s.repo.List(ctx, filter, normalizeReserveAcSort(sort), limit, offset)
}
func (s *ReserveAcService) AircraftExists(ctx context.Context, helicopterID []byte) (bool, error) {
return s.repo.AircraftExists(ctx, helicopterID)
}
func (s *ReserveAcService) FlightInspectionExists(ctx context.Context, inspectionID []byte) (bool, error) {
return s.repo.FlightInspectionExists(ctx, inspectionID)
}
func (s *ReserveAcService) ExistsActiveByInspectionID(ctx context.Context, inspectionID []byte, excludeID []byte) (bool, error) {
return s.repo.ExistsActiveByInspectionID(ctx, inspectionID, excludeID)
}
func (s *ReserveAcService) GetLastDailyInspectionByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.LastDailyInspection, error) {
return s.repo.GetLastDailyInspectionByHelicopterID(ctx, helicopterID)
}
func (s *ReserveAcService) GetLastDailyInspectionSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.LastDailyInspectionSummary, error) {
type lastDailyInspectionSummaryRepository interface {
GetLastDailyInspectionSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.LastDailyInspectionSummary, error)
}
if repo, ok := any(s.repo).(lastDailyInspectionSummaryRepository); ok {
return repo.GetLastDailyInspectionSummaryByHelicopterID(ctx, helicopterID)
}
return nil, nil
}
func normalizeReserveAcSort(sort string) string {
if normalized := normalizeSortByRules(strings.TrimSpace(sort),
sortExpr("status ASC, created_at ASC", "status DESC, created_at DESC", "status"),
sortField("created_at"),
sortField("updated_at"),
); normalized != "" {
return normalized
}
return "created_at DESC"
}

View File

@@ -0,0 +1,555 @@
package service
import (
"bytes"
"context"
"strings"
"time"
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
"wucher/internal/domain/flight"
flightinspection "wucher/internal/domain/flight_inspection"
flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
flightprepcheck "wucher/internal/domain/flight_prep_check"
helicopterfile "wucher/internal/domain/helicopter_file"
reserveac "wucher/internal/domain/reserve_ac"
"wucher/internal/shared/pkg/uuidv7"
requestdto "wucher/internal/transport/http/dto/request"
)
// ReserveAcUpdateError carries structured validation/update failures from the
// application service layer back to the HTTP handler.
type ReserveAcUpdateError struct {
Status int
Title string
Detail string
Pointer string
}
func (e *ReserveAcUpdateError) Error() string {
if e == nil {
return ""
}
return e.Detail
}
type ReserveAcUpdateInput struct {
ID []byte
ActorUserID []byte
AircraftID *string
FlightID *string
Inspection *requestdto.ReserveAcInspectionUpdateAttributes
Flight reserveAcFlightService
Inspect flightinspection.FlightInspectionService
Before beforeflightinspection.Service
Prep flightprepcheck.Service
HeliFile helicopterfile.Service
FileChecklist flightinspectionfilechecklist.Service
}
type reserveAcFlightService interface {
GetByID(ctx context.Context, id []byte) (*flight.Flight, error)
GetByReserveAcID(ctx context.Context, reserveAcID []byte) (*flight.Flight, error)
Update(ctx context.Context, row *flight.Flight) error
}
type ReserveAcCreateInput struct {
HelicopterID []byte
FlightID []byte
InspectionDate time.Time
Before *requestdto.ReserveAcBeforeInspectionAttributes
Prepare *requestdto.ReserveAcPrepareInspectionAttributes
ActorUserID []byte
Flight reserveAcFlightService
Inspect flightinspection.FlightInspectionService
BeforeSvc beforeflightinspection.Service
Prep flightprepcheck.Service
HeliFile helicopterfile.Service
FileChecklist flightinspectionfilechecklist.Service
}
func (s *ReserveAcService) UpdateDetailed(ctx context.Context, in ReserveAcUpdateInput) (*reserveac.ReserveAc, error) {
if s == nil || s.repo == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "reserve ac service is not configured"}
}
if len(in.ID) != 16 {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "id is invalid UUID", Pointer: "/data/id"}
}
existing, err := s.repo.GetByID(ctx, in.ID)
if err != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: err.Error()}
}
if existing == nil {
return nil, &ReserveAcUpdateError{Status: 404, Title: "Not found", Detail: "reserve_ac not found"}
}
if in.FlightID != nil {
if in.Flight == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "flight service is not configured"}
}
flightValue := strings.TrimSpace(*in.FlightID)
flightID, parseErr := uuidv7.ParseString(flightValue)
if parseErr != nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "flight_id is invalid UUID", Pointer: "/data/attributes/flight_id"}
}
targetFlight, flightErr := in.Flight.GetByID(ctx, flightID)
if flightErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: flightErr.Error()}
}
if targetFlight == nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "flight_id does not exist", Pointer: "/data/attributes/flight_id"}
}
if len(targetFlight.ReserveAcID) == 16 && !bytes.Equal(targetFlight.ReserveAcID, existing.ID) {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "flight already has reserve_ac assigned", Pointer: "/data/attributes/flight_id"}
}
currentFlight, currentErr := in.Flight.GetByReserveAcID(ctx, existing.ID)
if currentErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: currentErr.Error()}
}
if currentFlight != nil && !bytes.Equal(currentFlight.ID, targetFlight.ID) {
currentFlight.ReserveAcID = nil
currentFlight.UpdatedBy = in.ActorUserID
if updateErr := in.Flight.Update(ctx, currentFlight); updateErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: updateErr.Error()}
}
}
if len(targetFlight.ReserveAcID) != 16 || !bytes.Equal(targetFlight.ReserveAcID, existing.ID) {
targetFlight.ReserveAcID = append([]byte(nil), existing.ID...)
targetFlight.UpdatedBy = in.ActorUserID
if updateErr := in.Flight.Update(ctx, targetFlight); updateErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: updateErr.Error()}
}
}
}
if in.AircraftID != nil && strings.TrimSpace(*in.AircraftID) != "" {
helicopterValue := strings.TrimSpace(*in.AircraftID)
helicopterID, parseErr := uuidv7.ParseString(helicopterValue)
if parseErr != nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "helicopter_id is invalid UUID", Pointer: "/data/attributes/helicopter_id"}
}
exists, existsErr := s.repo.AircraftExists(ctx, helicopterID)
if existsErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: existsErr.Error()}
}
if !exists {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "helicopter_id does not exist", Pointer: "/data/attributes/helicopter_id"}
}
existing.AircraftID = helicopterID
}
if in.Inspection != nil {
if in.Inspect == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "flight inspection service is not configured"}
}
if in.Before == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "before flight inspection service is not configured"}
}
if in.Prep == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "flight prep check service is not configured"}
}
if in.HeliFile == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "helicopter file service is not configured"}
}
if in.FileChecklist == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Update failed", Detail: "flight inspection file checklist service is not configured"}
}
inspectionRow, getErr := in.Inspect.GetByID(ctx, existing.InspectionID)
if getErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: getErr.Error()}
}
if inspectionRow == nil {
return nil, &ReserveAcUpdateError{Status: 404, Title: "Not found", Detail: "flight inspection not found"}
}
if in.Inspection.InspectionDate != nil {
inspectionDate, parseErr := time.Parse("2006-01-02", strings.TrimSpace(*in.Inspection.InspectionDate))
if parseErr != nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "inspection.inspection_date must be YYYY-MM-DD", Pointer: "/data/attributes/inspection/inspection_date"}
}
inspectionRow.InspectionDate = inspectionDate.UTC()
inspectionRow.UpdatedBy = in.ActorUserID
if updateErr := in.Inspect.Update(ctx, inspectionRow); updateErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: updateErr.Error()}
}
}
if in.Inspection.Before != nil {
beforeReq := &beforeflightinspection.UpsertRequest{
OilEngineNR1Checked: in.Inspection.Before.OilEngineNR1Checked,
OilEngineNR2Checked: in.Inspection.Before.OilEngineNR2Checked,
HydraulicLHChecked: in.Inspection.Before.HydraulicLHChecked,
HydraulicRHChecked: in.Inspection.Before.HydraulicRHChecked,
OilTransmissionMGBChecked: in.Inspection.Before.OilTransmissionMGBChecked,
OilTransmissionIGBChecked: in.Inspection.Before.OilTransmissionIGBChecked,
OilTransmissionTGBChecked: in.Inspection.Before.OilTransmissionTGBChecked,
FuelAmount: in.Inspection.Before.FuelAmount,
FuelUnit: in.Inspection.Before.FuelUnit,
FuelAddedAmount: in.Inspection.Before.FuelAddedAmount,
Note: in.Inspection.Before.Note,
}
if _, upsertErr := in.Before.Upsert(ctx, existing.InspectionID, beforeReq); upsertErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: upsertErr.Error()}
}
if in.Inspection.Before.FileChecklist != nil {
if err := s.upsertInspectionFileChecklistSection(ctx, in, existing.InspectionID, existing.AircraftID, helicopterfile.SectionBeforeFirstFlightInspection, in.Inspection.Before.FileChecklist, "/data/attributes/inspection/before/file_checklist", existing.ID); err != nil {
return nil, err
}
}
}
if in.Inspection.Prepare != nil {
if _, upsertErr := in.Prep.UpsertByFlightInspection(ctx, existing.InspectionID, &flightprepcheck.UpsertRequest{
NOTAMBriefing: in.Inspection.Prepare.NotamBriefing,
WeatherBriefing: in.Inspection.Prepare.WeatherBriefing,
OperationalFlightPlan: in.Inspection.Prepare.OperationalFlightPlan,
}); upsertErr != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: upsertErr.Error()}
}
if in.Inspection.Prepare.FileChecklist != nil {
if err := s.upsertInspectionFileChecklistSection(ctx, in, existing.InspectionID, existing.AircraftID, helicopterfile.SectionFlightPreparationAndWB, in.Inspection.Prepare.FileChecklist, "/data/attributes/inspection/prepare/file_checklist", existing.ID); err != nil {
return nil, err
}
}
}
}
existing.UpdatedBy = in.ActorUserID
existing.Status = s.calculateReserveAcStatus(ctx, in, existing.InspectionID, existing.AircraftID)
if err := s.Update(ctx, existing); err != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Update failed", Detail: err.Error()}
}
return existing, nil
}
func (s *ReserveAcService) CreateDetailed(ctx context.Context, in ReserveAcCreateInput) (*reserveac.ReserveAc, error) {
if s == nil || s.repo == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "reserve ac service is not configured"}
}
if len(in.HelicopterID) != 16 {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "helicopter_id is invalid UUID", Pointer: "/data/attributes/helicopter_id"}
}
if len(in.FlightID) != 16 {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "flight_id is invalid UUID", Pointer: "/data/attributes/flight_id"}
}
if in.Before == nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "inspection.before is required", Pointer: "/data/attributes/inspection/before"}
}
if in.Prepare == nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "inspection.prepare is required", Pointer: "/data/attributes/inspection/prepare"}
}
if in.Flight == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "flight service is not configured"}
}
if in.Inspect == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "flight inspection service is not configured"}
}
if in.BeforeSvc == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "before flight inspection service is not configured"}
}
if in.Prep == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "flight prep check service is not configured"}
}
if in.HeliFile == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "helicopter file service is not configured"}
}
if in.FileChecklist == nil {
return nil, &ReserveAcUpdateError{Status: 500, Title: "Create failed", Detail: "flight inspection file checklist service is not configured"}
}
flightRow, err := in.Flight.GetByID(ctx, in.FlightID)
if err != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
if flightRow == nil {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "flight_id does not exist", Pointer: "/data/attributes/flight_id"}
}
if len(flightRow.ReserveAcID) == 16 {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "flight already has reserve_ac assigned", Pointer: "/data/attributes/flight_id"}
}
exists, err := s.repo.AircraftExists(ctx, in.HelicopterID)
if err != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
if !exists {
return nil, &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "helicopter_id does not exist", Pointer: "/data/attributes/helicopter_id"}
}
inspection := &flightinspection.FlightInspection{
InspectionDate: in.InspectionDate.UTC(),
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := in.Inspect.CreateDraft(ctx, inspection); err != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
row := &reserveac.ReserveAc{
Status: reserveac.StatusPending,
AircraftID: append([]byte(nil), in.HelicopterID...),
InspectionID: append([]byte(nil), inspection.ID...),
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := s.Create(ctx, row); err != nil {
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
beforeReq := &beforeflightinspection.UpsertRequest{
OilEngineNR1Checked: in.Before.OilEngineNR1Checked,
OilEngineNR2Checked: in.Before.OilEngineNR2Checked,
HydraulicLHChecked: in.Before.HydraulicLHChecked,
HydraulicRHChecked: in.Before.HydraulicRHChecked,
OilTransmissionMGBChecked: in.Before.OilTransmissionMGBChecked,
OilTransmissionIGBChecked: in.Before.OilTransmissionIGBChecked,
OilTransmissionTGBChecked: in.Before.OilTransmissionTGBChecked,
FuelAmount: in.Before.FuelAmount,
FuelUnit: in.Before.FuelUnit,
FuelAddedAmount: in.Before.FuelAddedAmount,
Note: in.Before.Note,
}
if _, err := in.BeforeSvc.Upsert(ctx, inspection.ID, beforeReq); err != nil {
_ = s.Delete(ctx, row.ID, in.ActorUserID)
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
if _, err := in.Prep.InitializeChecks(ctx, inspection.ID); err != nil {
_ = s.Delete(ctx, row.ID, in.ActorUserID)
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
if _, err := in.Prep.UpsertByFlightInspection(ctx, inspection.ID, &flightprepcheck.UpsertRequest{
NOTAMBriefing: in.Prepare.NotamBriefing,
WeatherBriefing: in.Prepare.WeatherBriefing,
OperationalFlightPlan: in.Prepare.OperationalFlightPlan,
}); err != nil {
_ = s.Delete(ctx, row.ID, in.ActorUserID)
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
if in.Before.FileChecklist != nil {
if err := s.upsertInspectionFileChecklistSection(ctx, ReserveAcUpdateInput{
ActorUserID: in.ActorUserID,
HeliFile: in.HeliFile,
FileChecklist: in.FileChecklist,
}, inspection.ID, in.HelicopterID, helicopterfile.SectionBeforeFirstFlightInspection, in.Before.FileChecklist, "/data/attributes/inspection/before/file_checklist", row.ID); err != nil {
return nil, err
}
}
if in.Prepare.FileChecklist != nil {
if err := s.upsertInspectionFileChecklistSection(ctx, ReserveAcUpdateInput{
ActorUserID: in.ActorUserID,
HeliFile: in.HeliFile,
FileChecklist: in.FileChecklist,
}, inspection.ID, in.HelicopterID, helicopterfile.SectionFlightPreparationAndWB, in.Prepare.FileChecklist, "/data/attributes/inspection/prepare/file_checklist", row.ID); err != nil {
return nil, err
}
}
flightRow.ReserveAcID = append([]byte(nil), row.ID...)
flightRow.UpdatedBy = in.ActorUserID
if err := in.Flight.Update(ctx, flightRow); err != nil {
_ = s.Delete(ctx, row.ID, in.ActorUserID)
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
row.Status = s.calculateReserveAcStatus(ctx, ReserveAcUpdateInput{
Inspect: in.Inspect,
Before: in.BeforeSvc,
Prep: in.Prep,
HeliFile: in.HeliFile,
FileChecklist: in.FileChecklist,
}, inspection.ID, in.HelicopterID)
if err := s.Update(ctx, row); err != nil {
_ = s.Delete(ctx, row.ID, in.ActorUserID)
return nil, &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
return row, nil
}
func (s *ReserveAcService) calculateReserveAcStatus(ctx context.Context, in ReserveAcUpdateInput, inspectionID []byte, helicopterID []byte) string {
if len(inspectionID) != 16 || len(helicopterID) != 16 {
return reserveac.StatusPending
}
if in.Inspect == nil || in.Before == nil || in.Prep == nil || in.HeliFile == nil || in.FileChecklist == nil {
return reserveac.StatusPending
}
inspectionRow, err := in.Inspect.GetByID(ctx, inspectionID)
if err != nil || inspectionRow == nil {
return reserveac.StatusPending
}
ckRows, err := in.FileChecklist.ListByInspectionID(ctx, inspectionID)
if err != nil {
return reserveac.StatusPending
}
isDoneByHeliFileID := make(map[string]bool, len(ckRows))
for i := range ckRows {
isDoneByHeliFileID[string(ckRows[i].HelicopterFileID)] = ckRows[i].IsDone
}
beforeRows, err := in.HeliFile.ListByHelicopterAndSection(ctx, helicopterID, helicopterfile.SectionBeforeFirstFlightInspection)
if err != nil {
return reserveac.StatusPending
}
prepareRows, err := in.HeliFile.ListByHelicopterAndSection(ctx, helicopterID, helicopterfile.SectionFlightPreparationAndWB)
if err != nil {
return reserveac.StatusPending
}
beforeData, _ := in.Before.GetByFlightInspectionID(ctx, inspectionID)
prepareData, _ := in.Prep.GetByFlightInspection(ctx, inspectionID)
beforeDone := countCompletedChecklist(beforeRows, isDoneByHeliFileID)
prepareDone := countCompletedChecklist(prepareRows, isDoneByHeliFileID)
totalChecklist := len(beforeRows) + len(prepareRows)
doneChecklist := beforeDone + prepareDone
if totalChecklist > 0 {
if doneChecklist == 0 {
return reserveac.StatusPending
}
if doneChecklist < totalChecklist {
return reserveac.StatusOnProgress
}
return reserveac.StatusApproved
}
if beforeData != nil || prepareData != nil {
return reserveac.StatusOnProgress
}
return reserveac.StatusPending
}
func countCompletedChecklist(files []helicopterfile.HelicopterFile, doneByID map[string]bool) int {
if len(files) == 0 {
return 0
}
completed := 0
for i := range files {
if doneByID[string(files[i].ID)] {
completed++
}
}
return completed
}
func (s *ReserveAcService) upsertInspectionFileChecklistSection(
ctx context.Context,
in ReserveAcUpdateInput,
inspectionID []byte,
helicopterID []byte,
section string,
payload []requestdto.ReserveAcFileChecklistItem,
payloadPointer string,
reserveAcID []byte,
) error {
templateFiles, err := in.HeliFile.ListByHelicopterAndSection(ctx, helicopterID, section)
if err != nil {
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
type requestedChecklist struct {
fileID []byte
isDone bool
}
requested := make([]requestedChecklist, 0, len(payload))
for i := range payload {
fileID, parseErr := uuidv7.ParseString(strings.TrimSpace(payload[i].HelicopterFileID))
if parseErr != nil {
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "helicopter_file_id is invalid UUID", Pointer: payloadPointer}
}
requested = append(requested, requestedChecklist{
fileID: fileID,
isDone: payload[i].IsDone,
})
}
requestedByFileID := make(map[string]bool, len(requested))
for i := range requested {
requestedByFileID[string(requested[i].fileID)] = requested[i].isDone
}
existingRows, err := in.FileChecklist.ListByInspectionID(ctx, inspectionID)
if err != nil {
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
existingByFileID := make(map[string]*flightinspectionfilechecklist.FlightInspectionFileChecklist, len(existingRows))
for i := range existingRows {
fileID := string(existingRows[i].HelicopterFileID)
existingByFileID[fileID] = &existingRows[i]
}
templateSet := make(map[string]struct{}, len(templateFiles))
for i := range templateFiles {
fileKey := string(templateFiles[i].ID)
templateSet[fileKey] = struct{}{}
current := existingByFileID[fileKey]
targetDone, hasOverride := requestedByFileID[fileKey]
if templateFiles[i].IsMandatory && !targetDone {
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "file_checklist must be fully checked", Pointer: payloadPointer}
}
if current == nil {
if !hasOverride {
targetDone = false
}
newRow := &flightinspectionfilechecklist.FlightInspectionFileChecklist{
FlightInspectionID: inspectionID,
HelicopterFileID: templateFiles[i].ID,
IsDone: targetDone,
CreatedBy: in.ActorUserID,
UpdatedBy: in.ActorUserID,
}
if err := in.FileChecklist.Create(ctx, newRow); err != nil {
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
continue
}
if !hasOverride {
targetDone = current.IsDone
}
if current.IsDone != targetDone {
current.IsDone = targetDone
current.UpdatedBy = in.ActorUserID
if err := in.FileChecklist.Update(ctx, current); err != nil {
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 400, Title: "Create failed", Detail: err.Error()}
}
}
}
for i := range requested {
if _, ok := templateSet[string(requested[i].fileID)]; ok {
continue
}
if len(reserveAcID) == 16 {
_ = s.Delete(ctx, reserveAcID, in.ActorUserID)
}
return &ReserveAcUpdateError{Status: 422, Title: "Validation error", Detail: "helicopter_file_id does not belong to selected helicopter/section", Pointer: payloadPointer}
}
return nil
}

View File

@@ -0,0 +1,98 @@
package service
import (
"context"
"errors"
"strings"
"wucher/internal/domain/auth"
)
type RoleService struct {
repo auth.RoleRepository
}
func NewRoleService(repo auth.RoleRepository) *RoleService {
return &RoleService{repo: repo}
}
func (s *RoleService) Create(ctx context.Context, role *auth.Role) error {
if strings.TrimSpace(role.Name) == "" {
return errors.New("name is required")
}
return s.repo.CreateRole(ctx, role)
}
func (s *RoleService) Update(ctx context.Context, role *auth.Role) error {
if strings.TrimSpace(role.Name) == "" {
return errors.New("name is required")
}
return s.repo.UpdateRole(ctx, role)
}
func (s *RoleService) Delete(ctx context.Context, id []byte) error {
return s.repo.DeleteRole(ctx, id)
}
func (s *RoleService) GetByID(ctx context.Context, id []byte) (*auth.Role, error) {
return s.repo.GetRoleByID(ctx, id)
}
func (s *RoleService) GetByName(ctx context.Context, name string) (*auth.Role, error) {
return s.repo.GetRoleByName(ctx, name)
}
func (s *RoleService) GetPermissionByID(ctx context.Context, id []byte) (*auth.Permission, error) {
return s.repo.GetPermissionByID(ctx, id)
}
func (s *RoleService) GetPermissionByKey(ctx context.Context, key string) (*auth.Permission, error) {
return s.repo.GetPermissionByKey(ctx, key)
}
func (s *RoleService) UpdatePermission(ctx context.Context, permission *auth.Permission) error {
if strings.TrimSpace(permission.Name) == "" {
return errors.New("name is required")
}
if strings.TrimSpace(permission.Key) == "" {
return errors.New("key is required")
}
return s.repo.UpdatePermission(ctx, permission)
}
func (s *RoleService) AssignPermission(ctx context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) {
return s.repo.AssignPermission(ctx, roleID, permissionID)
}
func (s *RoleService) RemovePermission(ctx context.Context, roleID, permissionID []byte) error {
return s.repo.RemovePermission(ctx, roleID, permissionID)
}
func (s *RoleService) ListPermissions(ctx context.Context, filter, sort string, limit, offset int) ([]auth.Permission, int64, error) {
return s.repo.ListPermissions(ctx, filter, normalizePermissionSort(sort), limit, offset)
}
func (s *RoleService) ListPermissionsByRoleID(ctx context.Context, roleID []byte) ([]auth.Permission, error) {
return s.repo.ListPermissionsByRoleID(ctx, roleID)
}
func (s *RoleService) List(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) {
return s.repo.ListRoles(ctx, filterName, normalizeSort(sort), limit, offset)
}
func normalizeSort(sort string) string {
return normalizeSortByRules(sort,
sortField("name"),
sortField("created_at"),
sortField("updated_at"),
)
}
func normalizePermissionSort(sort string) string {
return normalizeSortByRules(sort,
sortField("name"),
sortField("`key`", "key"),
sortField("created_at"),
sortField("updated_at"),
)
}

Some files were not shown because too many files have changed in this diff Show More