Files
GL/application/ledger/service_test.go

224 lines
7.0 KiB
Go

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 }
}