init push
This commit is contained in:
124
internal/service/action_signoff_service.go
Normal file
124
internal/service/action_signoff_service.go
Normal 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
|
||||
}
|
||||
168
internal/service/after_flight_inspection_service.go
Normal file
168
internal/service/after_flight_inspection_service.go
Normal 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) != ""
|
||||
}
|
||||
168
internal/service/after_flight_inspection_service_test.go
Normal file
168
internal/service/after_flight_inspection_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
289
internal/service/air_rescuer_checklist_service.go
Normal file
289
internal/service/air_rescuer_checklist_service.go
Normal 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
|
||||
}
|
||||
986
internal/service/air_rescuer_checklist_service_test.go
Normal file
986
internal/service/air_rescuer_checklist_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
315
internal/service/audit_log_service.go
Normal file
315
internal/service/audit_log_service.go
Normal 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
|
||||
}
|
||||
288
internal/service/audit_log_service_test.go
Normal file
288
internal/service/audit_log_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
559
internal/service/auth_email_template.go
Normal file
559
internal/service/auth_email_template.go
Normal 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;"> </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;"> </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(" ‌ ", 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;"> </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.",
|
||||
})
|
||||
}
|
||||
3592
internal/service/auth_service.go
Normal file
3592
internal/service/auth_service.go
Normal file
File diff suppressed because it is too large
Load Diff
5617
internal/service/auth_service_test.go
Normal file
5617
internal/service/auth_service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
993
internal/service/auth_sessions.go
Normal file
993
internal/service/auth_sessions.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
411
internal/service/auth_webauthn.go
Normal file
411
internal/service/auth_webauthn.go
Normal 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
|
||||
}
|
||||
1007
internal/service/auth_webauthn_test.go
Normal file
1007
internal/service/auth_webauthn_test.go
Normal file
File diff suppressed because it is too large
Load Diff
151
internal/service/base_service.go
Normal file
151
internal/service/base_service.go
Normal 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))
|
||||
}
|
||||
}
|
||||
278
internal/service/base_service_test.go
Normal file
278
internal/service/base_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
416
internal/service/base_write_service.go
Normal file
416
internal/service/base_write_service.go
Normal 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"
|
||||
}
|
||||
149
internal/service/before_flight_inspection_service.go
Normal file
149
internal/service/before_flight_inspection_service.go
Normal 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
|
||||
}
|
||||
98
internal/service/before_flight_inspection_service_test.go
Normal file
98
internal/service/before_flight_inspection_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
74
internal/service/complaint_service.go
Normal file
74
internal/service/complaint_service.go
Normal 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
|
||||
}
|
||||
141
internal/service/complaint_service_test.go
Normal file
141
internal/service/complaint_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
158
internal/service/contact_service.go
Normal file
158
internal/service/contact_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
504
internal/service/contact_service_test.go
Normal file
504
internal/service/contact_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
29
internal/service/cookies.go
Normal file
29
internal/service/cookies.go
Normal 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
|
||||
}
|
||||
14
internal/service/cookies_test.go
Normal file
14
internal/service/cookies_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
61
internal/service/dul_service.go
Normal file
61
internal/service/dul_service.go
Normal 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
|
||||
}
|
||||
1246
internal/service/duty_roster_service.go
Normal file
1246
internal/service/duty_roster_service.go
Normal file
File diff suppressed because it is too large
Load Diff
1222
internal/service/duty_roster_service_test.go
Normal file
1222
internal/service/duty_roster_service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
109
internal/service/easa_release_service.go
Normal file
109
internal/service/easa_release_service.go
Normal 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
|
||||
}
|
||||
173
internal/service/easa_release_service_test.go
Normal file
173
internal/service/easa_release_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
119
internal/service/email_job_handler.go
Normal file
119
internal/service/email_job_handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
93
internal/service/email_job_handler_test.go
Normal file
93
internal/service/email_job_handler_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
43
internal/service/email_queue.go
Normal file
43
internal/service/email_queue.go
Normal 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}
|
||||
}
|
||||
38
internal/service/email_queue_test.go
Normal file
38
internal/service/email_queue_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
138
internal/service/email_raw_mime.go
Normal file
138
internal/service/email_raw_mime.go
Normal 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), `"`, `'`)
|
||||
}
|
||||
17
internal/service/email_sender.go
Normal file
17
internal/service/email_sender.go
Normal 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)
|
||||
}
|
||||
52
internal/service/facility_service.go
Normal file
52
internal/service/facility_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
227
internal/service/facility_service_test.go
Normal file
227
internal/service/facility_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
internal/service/federal_state_service.go
Normal file
51
internal/service/federal_state_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
217
internal/service/federal_state_service_test.go
Normal file
217
internal/service/federal_state_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
2974
internal/service/file_manager_service.go
Normal file
2974
internal/service/file_manager_service.go
Normal file
File diff suppressed because it is too large
Load Diff
2620
internal/service/file_manager_service_test.go
Normal file
2620
internal/service/file_manager_service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
33
internal/service/file_processing_queue.go
Normal file
33
internal/service/file_processing_queue.go
Normal 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}
|
||||
}
|
||||
769
internal/service/file_processing_usecase.go
Normal file
769
internal/service/file_processing_usecase.go
Normal 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
|
||||
}
|
||||
416
internal/service/file_processing_usecase_test.go
Normal file
416
internal/service/file_processing_usecase_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
internal/service/fleet_history_service.go
Normal file
42
internal/service/fleet_history_service.go
Normal 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)
|
||||
}
|
||||
11
internal/service/fleet_status_file_service.go
Normal file
11
internal/service/fleet_status_file_service.go
Normal 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}
|
||||
}
|
||||
664
internal/service/fleet_status_service.go
Normal file
664
internal/service/fleet_status_service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
267
internal/service/fleet_status_service_test.go
Normal file
267
internal/service/fleet_status_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
286
internal/service/fleet_status_stateful_test.go
Normal file
286
internal/service/fleet_status_stateful_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
95
internal/service/fleet_status_update_test.go
Normal file
95
internal/service/fleet_status_update_test.go
Normal 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)
|
||||
}
|
||||
332
internal/service/flight_data_service.go
Normal file
332
internal/service/flight_data_service.go
Normal 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
|
||||
}
|
||||
468
internal/service/flight_data_service_test.go
Normal file
468
internal/service/flight_data_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
internal/service/flight_inspection_file_checklist_service.go
Normal file
59
internal/service/flight_inspection_file_checklist_service.go
Normal 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"
|
||||
}
|
||||
51
internal/service/flight_inspection_service.go
Normal file
51
internal/service/flight_inspection_service.go
Normal 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)
|
||||
}
|
||||
151
internal/service/flight_prep_check_service.go
Normal file
151
internal/service/flight_prep_check_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
92
internal/service/flight_service.go
Normal file
92
internal/service/flight_service.go
Normal 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"
|
||||
}
|
||||
289
internal/service/fm_report_service.go
Normal file
289
internal/service/fm_report_service.go
Normal 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)
|
||||
}
|
||||
356
internal/service/fm_report_service_test.go
Normal file
356
internal/service/fm_report_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
internal/service/fm_report_snapshot_test.go
Normal file
52
internal/service/fm_report_snapshot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
51
internal/service/forces_present_service.go
Normal file
51
internal/service/forces_present_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
223
internal/service/forces_present_service_test.go
Normal file
223
internal/service/forces_present_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
internal/service/health_insurance_companies_service.go
Normal file
63
internal/service/health_insurance_companies_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
275
internal/service/health_insurance_companies_service_test.go
Normal file
275
internal/service/health_insurance_companies_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
3
internal/service/heavy_service.go
Normal file
3
internal/service/heavy_service.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package service
|
||||
|
||||
// Placeholder for future heavy/background operations.
|
||||
561
internal/service/helicopter_file_service.go
Normal file
561
internal/service/helicopter_file_service.go
Normal 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"
|
||||
}
|
||||
374
internal/service/helicopter_file_service_test.go
Normal file
374
internal/service/helicopter_file_service_test.go
Normal 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
|
||||
}
|
||||
113
internal/service/helicopter_service.go
Normal file
113
internal/service/helicopter_service.go
Normal 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
319
internal/service/helicopter_service_test.go
Normal file
319
internal/service/helicopter_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
internal/service/helicopter_usage_service.go
Normal file
89
internal/service/helicopter_usage_service.go
Normal 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)
|
||||
}
|
||||
443
internal/service/helicopter_write_service.go
Normal file
443
internal/service/helicopter_write_service.go
Normal 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)
|
||||
}
|
||||
44
internal/service/hems_operation_category_service.go
Normal file
44
internal/service/hems_operation_category_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
43
internal/service/hems_operation_service.go
Normal file
43
internal/service/hems_operation_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
44
internal/service/hems_operational_data_service.go
Normal file
44
internal/service/hems_operational_data_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
55
internal/service/hospital_service.go
Normal file
55
internal/service/hospital_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
233
internal/service/hospital_service_test.go
Normal file
233
internal/service/hospital_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
internal/service/icao_service.go
Normal file
56
internal/service/icao_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
231
internal/service/icao_service_test.go
Normal file
231
internal/service/icao_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
internal/service/insurance_patient_data_service.go
Normal file
42
internal/service/insurance_patient_data_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
81
internal/service/land_service.go
Normal file
81
internal/service/land_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
221
internal/service/land_service_test.go
Normal file
221
internal/service/land_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
439
internal/service/master_settings_service.go
Normal file
439
internal/service/master_settings_service.go
Normal 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
|
||||
}
|
||||
434
internal/service/master_settings_service_test.go
Normal file
434
internal/service/master_settings_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
47
internal/service/mcf_service.go
Normal file
47
internal/service/mcf_service.go
Normal 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)
|
||||
}
|
||||
85
internal/service/mcf_service_test.go
Normal file
85
internal/service/mcf_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
176
internal/service/medicine_service.go
Normal file
176
internal/service/medicine_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
227
internal/service/medicine_service_test.go
Normal file
227
internal/service/medicine_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
332
internal/service/microsoft_sso.go
Normal file
332
internal/service/microsoft_sso.go
Normal 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[:])
|
||||
}
|
||||
442
internal/service/microsoft_sso_test.go
Normal file
442
internal/service/microsoft_sso_test.go
Normal 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"
|
||||
}
|
||||
356
internal/service/mission_service.go
Normal file
356
internal/service/mission_service.go
Normal 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"
|
||||
}
|
||||
581
internal/service/mission_service_test.go
Normal file
581
internal/service/mission_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
141
internal/service/mock_email_sender.go
Normal file
141
internal/service/mock_email_sender.go
Normal 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
|
||||
}
|
||||
66
internal/service/mock_email_sender_test.go
Normal file
66
internal/service/mock_email_sender_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
51
internal/service/opc_service.go
Normal file
51
internal/service/opc_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
223
internal/service/opc_service_test.go
Normal file
223
internal/service/opc_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
internal/service/other_person_service.go
Normal file
83
internal/service/other_person_service.go
Normal 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)
|
||||
}
|
||||
142
internal/service/other_person_service_test.go
Normal file
142
internal/service/other_person_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
internal/service/patient_data_service.go
Normal file
46
internal/service/patient_data_service.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
82
internal/service/reserve_ac_service.go
Normal file
82
internal/service/reserve_ac_service.go
Normal 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"
|
||||
}
|
||||
555
internal/service/reserve_ac_update_service.go
Normal file
555
internal/service/reserve_ac_update_service.go
Normal 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
|
||||
}
|
||||
98
internal/service/role_service.go
Normal file
98
internal/service/role_service.go
Normal 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
Reference in New Issue
Block a user