package ledger import ( "context" "errors" "strings" "testing" "time" domain "gl/domain/ledger" ) type repositoryStub struct { journals map[string]domain.Journal byKey map[string]string events map[string]domain.TransactionEvent appendCalls int list []domain.Journal balance domain.Amount lastAttempt domain.Journal } func newRepositoryStub() *repositoryStub { return &repositoryStub{ journals: make(map[string]domain.Journal), byKey: make(map[string]string), events: make(map[string]domain.TransactionEvent), } } func (r *repositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) { r.appendCalls++ r.lastAttempt = journal if existingID, ok := r.byKey[journal.IdempotencyKey]; ok { return domain.AppendResult{JournalID: existingID, AlreadyExists: true}, nil } journal.RecordedAt = time.Unix(2, 0).UTC() r.journals[journal.ID] = journal r.byKey[journal.IdempotencyKey] = journal.ID return domain.AppendResult{JournalID: journal.ID}, nil } func (r *repositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) { if existing, ok := r.events[event.IdempotencyKey]; ok { return existing, true, nil } event.RecordedAt = time.Unix(2, 0).UTC() r.events[event.IdempotencyKey] = event return event, false, nil } func (r *repositoryStub) GetByID(_ context.Context, id string) (domain.Journal, error) { journal, ok := r.journals[id] if !ok { return domain.Journal{}, errors.New("not found") } return journal, nil } func (r *repositoryStub) GetByIdempotencyKey(_ context.Context, key string) (domain.Journal, error) { id, ok := r.byKey[key] if !ok { return domain.Journal{}, errors.New("not found") } return r.journals[id], nil } func (r *repositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) { return append([]domain.Journal(nil), r.list...), nil } func (r *repositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) { return r.balance, nil } func TestAppendJournalIsBalancedCanonicalAndIdempotent(t *testing.T) { repository := newRepositoryStub() service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) command := validAppendCommand() first, err := service.AppendJournal(context.Background(), command) if err != nil { t.Fatal(err) } command.Entries[0], command.Entries[1] = command.Entries[1], command.Entries[0] second, err := service.AppendJournal(context.Background(), command) if err != nil { t.Fatal(err) } if first.AlreadyExisted || !second.AlreadyExisted { t.Fatalf("unexpected idempotency results: first=%+v second=%+v", first, second) } if first.Journal.PayloadHash == "" || first.Journal.PayloadHash != second.Journal.PayloadHash { t.Fatalf("unexpected payload hashes: %q %q", first.Journal.PayloadHash, second.Journal.PayloadHash) } if repository.lastAttempt.PayloadHash != first.Journal.PayloadHash { t.Fatal("entry order changed the canonical payload hash") } if got := first.Journal.Entries[0].Amount.String(); got != "-10.25" { t.Fatalf("amount was not canonical: %s", got) } } func TestAppendJournalRejectsInvalidAmountBeforeRepository(t *testing.T) { repository := newRepositoryStub() service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) command := validAppendCommand() command.Entries[0].Amount = "NaN" _, err := service.AppendJournal(context.Background(), command) if !errors.Is(err, ErrInvalidArgument) { t.Fatalf("expected invalid argument, got %v", err) } if repository.appendCalls != 0 { t.Fatal("invalid journal reached repository") } } func TestAppendJournalRequiresExactFullReversal(t *testing.T) { repository := newRepositoryStub() service := NewService(repository, fixedID("22222222-2222-4222-8222-222222222222")) originalCommand := validAppendCommand() original, err := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")).AppendJournal(context.Background(), originalCommand) if err != nil { t.Fatal(err) } reversal := validAppendCommand() reversal.IdempotencyKey = "wallet:1:internal-transfer-reversal:v1" reversal.EffectKind = "internal-transfer-reversal" reversal.ReversalOfJournalID = original.Journal.ID reversal.Entries[0].Amount = "10.25" reversal.Entries[1].Amount = "-10.25" if _, err := service.AppendJournal(context.Background(), reversal); err != nil { t.Fatalf("valid reversal failed: %v", err) } reversal.IdempotencyKey = "wallet:1:bad-reversal:v1" reversal.Entries[0].Account.OwnerID = "different-user" if _, err := service.AppendJournal(context.Background(), reversal); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("expected invalid reversal, got %v", err) } } func TestAppendEventIsIdempotent(t *testing.T) { repository := newRepositoryStub() service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) command := AppendEventCommand{ SourceService: "wallet", IdempotencyKey: "wallet:1:status:v1", SourceTransactionID: "1", EventVersion: 1, State: domain.TransactionStateCreated, OccurredAt: time.Unix(1, 0).UTC(), } first, existed, err := service.AppendEvent(context.Background(), command) if err != nil || existed { t.Fatalf("first append: existed=%v err=%v", existed, err) } second, existed, err := service.AppendEvent(context.Background(), command) if err != nil || !existed || first.PayloadHash != second.PayloadHash { t.Fatalf("second append: existed=%v err=%v first=%+v second=%+v", existed, err, first, second) } } func TestListUsesOpaquePageToken(t *testing.T) { repository := newRepositoryStub() repository.list = make([]domain.Journal, 3) service := NewService(repository, nil) journals, next, err := service.List(context.Background(), ListCommand{PageSize: 2}) if err != nil { t.Fatal(err) } if len(journals) != 2 || next == "" { t.Fatalf("unexpected page: len=%d next=%q", len(journals), next) } if _, err := decodePageToken(next); err != nil { t.Fatalf("invalid generated page token: %v", err) } } func TestNewUUIDProducesRFC4122Shape(t *testing.T) { id, err := newUUID() if err != nil { t.Fatal(err) } if len(id) != 36 || id[14] != '4' || !strings.Contains("89ab", string(id[19])) { t.Fatalf("unexpected UUID: %q", id) } } func validAppendCommand() AppendJournalCommand { return AppendJournalCommand{ SourceService: "wallet", IdempotencyKey: "wallet:1:internal-transfer:v1", SourceTransactionID: "1", EffectKind: "internal-transfer", EventVersion: 1, OccurredAt: time.Unix(1, 0).UTC(), Entries: []EntryCommand{ { LineNumber: 1, Account: domain.AccountReference{ Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5, }, Amount: "-10.2500", }, { LineNumber: 2, Account: domain.AccountReference{ Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5, }, Amount: "10.25", }, }, } } func fixedID(id string) IDGenerator { return func() (string, error) { return id, nil } }