224 lines
7.0 KiB
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 }
|
|
}
|