feat(gl): expose ledger application and grpc operations
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user