feat(gl): expose ledger application and grpc operations
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
// Package ledger implements GL's transport-independent use cases.
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
domain "gl/domain/ledger"
|
||||
)
|
||||
|
||||
var ErrInvalidArgument = errors.New("invalid ledger request")
|
||||
|
||||
type Repository interface {
|
||||
Append(context.Context, domain.Journal) (domain.AppendResult, error)
|
||||
AppendEvent(context.Context, domain.TransactionEvent) (domain.TransactionEvent, bool, error)
|
||||
GetByID(context.Context, string) (domain.Journal, error)
|
||||
GetByIdempotencyKey(context.Context, string) (domain.Journal, error)
|
||||
List(context.Context, domain.JournalFilter) ([]domain.Journal, error)
|
||||
Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error)
|
||||
}
|
||||
|
||||
type IDGenerator func() (string, error)
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
newID IDGenerator
|
||||
}
|
||||
|
||||
func NewService(repository Repository, newID IDGenerator) *Service {
|
||||
if newID == nil {
|
||||
newID = newUUID
|
||||
}
|
||||
return &Service{repository: repository, newID: newID}
|
||||
}
|
||||
|
||||
type EntryCommand struct {
|
||||
LineNumber uint32
|
||||
Account domain.AccountReference
|
||||
Amount string
|
||||
Description string
|
||||
}
|
||||
|
||||
type AppendJournalCommand struct {
|
||||
SourceService string
|
||||
IdempotencyKey string
|
||||
SourceTransactionID string
|
||||
TrackingCode string
|
||||
EffectKind string
|
||||
EventVersion uint32
|
||||
Entries []EntryCommand
|
||||
ReversalOfJournalID string
|
||||
OccurredAt time.Time
|
||||
CorrelationID string
|
||||
ActorID string
|
||||
Blockchain domain.BlockchainReference
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type AppendJournalResult struct {
|
||||
Journal domain.Journal
|
||||
AlreadyExisted bool
|
||||
}
|
||||
|
||||
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
journalID, err := s.newID()
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
|
||||
}
|
||||
journal := domain.Journal{
|
||||
ID: journalID,
|
||||
SourceService: command.SourceService,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
SourceTransactionID: command.SourceTransactionID,
|
||||
TrackingCode: command.TrackingCode,
|
||||
EffectKind: command.EffectKind,
|
||||
EventVersion: command.EventVersion,
|
||||
ReversalOfJournalID: command.ReversalOfJournalID,
|
||||
OccurredAt: command.OccurredAt,
|
||||
CorrelationID: command.CorrelationID,
|
||||
ActorID: command.ActorID,
|
||||
Blockchain: command.Blockchain,
|
||||
Metadata: cloneMetadata(command.Metadata),
|
||||
Entries: make([]domain.Entry, 0, len(command.Entries)),
|
||||
}
|
||||
for _, input := range command.Entries {
|
||||
amount, parseErr := domain.ParseAmount(input.Amount)
|
||||
if parseErr != nil {
|
||||
return AppendJournalResult{}, invalid("entry %d amount: %v", input.LineNumber, parseErr)
|
||||
}
|
||||
journal.Entries = append(journal.Entries, domain.Entry{
|
||||
LineNumber: input.LineNumber,
|
||||
Account: input.Account,
|
||||
Amount: amount,
|
||||
Description: input.Description,
|
||||
})
|
||||
}
|
||||
sort.Slice(journal.Entries, func(i, j int) bool {
|
||||
return journal.Entries[i].LineNumber < journal.Entries[j].LineNumber
|
||||
})
|
||||
|
||||
journal.PayloadHash, err = journalHash(journal)
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("hash journal: %w", err)
|
||||
}
|
||||
if err := journal.Validate(); err != nil {
|
||||
return AppendJournalResult{}, invalid("%v", err)
|
||||
}
|
||||
if journal.ReversalOfJournalID != "" {
|
||||
original, getErr := s.repository.GetByID(ctx, journal.ReversalOfJournalID)
|
||||
if getErr != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("read reversal target: %w", getErr)
|
||||
}
|
||||
if err := validateFullReversal(original, journal); err != nil {
|
||||
return AppendJournalResult{}, invalid("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
appendResult, err := s.repository.Append(ctx, journal)
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, err
|
||||
}
|
||||
stored, err := s.repository.GetByID(ctx, appendResult.JournalID)
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("read appended journal: %w", err)
|
||||
}
|
||||
return AppendJournalResult{Journal: stored, AlreadyExisted: appendResult.AlreadyExists}, nil
|
||||
}
|
||||
|
||||
type AppendEventCommand struct {
|
||||
SourceService string
|
||||
IdempotencyKey string
|
||||
SourceTransactionID string
|
||||
TrackingCode string
|
||||
EventVersion uint32
|
||||
State domain.TransactionState
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
OccurredAt time.Time
|
||||
CorrelationID string
|
||||
ActorID string
|
||||
Blockchain domain.BlockchainReference
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
func (s *Service) AppendEvent(ctx context.Context, command AppendEventCommand) (domain.TransactionEvent, bool, error) {
|
||||
eventID, err := s.newID()
|
||||
if err != nil {
|
||||
return domain.TransactionEvent{}, false, fmt.Errorf("generate event id: %w", err)
|
||||
}
|
||||
event := domain.TransactionEvent{
|
||||
ID: eventID,
|
||||
SourceService: command.SourceService,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
SourceTransactionID: command.SourceTransactionID,
|
||||
TrackingCode: command.TrackingCode,
|
||||
EventVersion: command.EventVersion,
|
||||
State: command.State,
|
||||
ErrorCode: command.ErrorCode,
|
||||
ErrorMessage: command.ErrorMessage,
|
||||
OccurredAt: command.OccurredAt,
|
||||
CorrelationID: command.CorrelationID,
|
||||
ActorID: command.ActorID,
|
||||
Blockchain: command.Blockchain,
|
||||
Metadata: cloneMetadata(command.Metadata),
|
||||
}
|
||||
event.PayloadHash, err = eventHash(event)
|
||||
if err != nil {
|
||||
return domain.TransactionEvent{}, false, fmt.Errorf("hash transaction event: %w", err)
|
||||
}
|
||||
if err := event.Validate(); err != nil {
|
||||
return domain.TransactionEvent{}, false, invalid("%v", err)
|
||||
}
|
||||
return s.repository.AppendEvent(ctx, event)
|
||||
}
|
||||
|
||||
func (s *Service) GetJournal(ctx context.Context, journalID, idempotencyKey string) (domain.Journal, error) {
|
||||
if (journalID == "") == (idempotencyKey == "") {
|
||||
return domain.Journal{}, invalid("exactly one journal lookup is required")
|
||||
}
|
||||
if journalID != "" {
|
||||
return s.repository.GetByID(ctx, journalID)
|
||||
}
|
||||
return s.repository.GetByIdempotencyKey(ctx, idempotencyKey)
|
||||
}
|
||||
|
||||
type ListCommand struct {
|
||||
Account *domain.AccountReference
|
||||
AssetID *int64
|
||||
RecordedFrom *time.Time
|
||||
RecordedTo *time.Time
|
||||
PageSize uint32
|
||||
PageToken string
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, command ListCommand) ([]domain.Journal, string, error) {
|
||||
if command.Account != nil {
|
||||
if err := command.Account.Validate(); err != nil {
|
||||
return nil, "", invalid("account: %v", err)
|
||||
}
|
||||
}
|
||||
if command.AssetID != nil && *command.AssetID <= 0 {
|
||||
return nil, "", invalid("asset id must be positive")
|
||||
}
|
||||
pageSize := int(command.PageSize)
|
||||
if pageSize == 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 200 {
|
||||
return nil, "", invalid("page size must not exceed 200")
|
||||
}
|
||||
offset, err := decodePageToken(command.PageToken)
|
||||
if err != nil {
|
||||
return nil, "", invalid("page token: %v", err)
|
||||
}
|
||||
journals, err := s.repository.List(ctx, domain.JournalFilter{
|
||||
Account: command.Account,
|
||||
AssetID: command.AssetID,
|
||||
RecordedFrom: command.RecordedFrom,
|
||||
RecordedTo: command.RecordedTo,
|
||||
Limit: pageSize + 1,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(journals) > pageSize {
|
||||
journals = journals[:pageSize]
|
||||
next = encodePageToken(offset + pageSize)
|
||||
}
|
||||
return journals, next, nil
|
||||
}
|
||||
|
||||
func (s *Service) Balance(ctx context.Context, account domain.AccountReference, asOf time.Time) (domain.Amount, error) {
|
||||
if err := account.Validate(); err != nil {
|
||||
return domain.Amount{}, invalid("account: %v", err)
|
||||
}
|
||||
return s.repository.Balance(ctx, account, asOf)
|
||||
}
|
||||
|
||||
func (s *Service) Replay(ctx context.Context, commands []AppendJournalCommand) ([]AppendJournalResult, error) {
|
||||
if len(commands) == 0 || len(commands) > 200 {
|
||||
return nil, invalid("replay batch must contain between 1 and 200 journals")
|
||||
}
|
||||
results := make([]AppendJournalResult, 0, len(commands))
|
||||
for _, command := range commands {
|
||||
result, err := s.AppendJournal(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func validateFullReversal(original, reversal domain.Journal) error {
|
||||
if len(original.Entries) != len(reversal.Entries) {
|
||||
return fmt.Errorf("reversal must contain every original entry")
|
||||
}
|
||||
expected := make(map[string]int, len(original.Entries))
|
||||
for _, entry := range original.Entries {
|
||||
expected[entryKey(entry.Account, entry.Amount.Negate())]++
|
||||
}
|
||||
for _, entry := range reversal.Entries {
|
||||
key := entryKey(entry.Account, entry.Amount)
|
||||
if expected[key] == 0 {
|
||||
return fmt.Errorf("reversal entries must exactly negate the original journal")
|
||||
}
|
||||
expected[key]--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func entryKey(account domain.AccountReference, amount domain.Amount) string {
|
||||
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", account.Class, account.OwnerType, account.OwnerID, account.AssetID, amount.String())
|
||||
}
|
||||
|
||||
func journalHash(journal domain.Journal) (string, error) {
|
||||
entries := make([]map[string]any, 0, len(journal.Entries))
|
||||
for _, entry := range journal.Entries {
|
||||
entries = append(entries, map[string]any{
|
||||
"line_number": entry.LineNumber,
|
||||
"class": entry.Account.Class,
|
||||
"owner_type": entry.Account.OwnerType,
|
||||
"owner_id": entry.Account.OwnerID,
|
||||
"asset_id": entry.Account.AssetID,
|
||||
"amount": entry.Amount.String(),
|
||||
"description": entry.Description,
|
||||
})
|
||||
}
|
||||
return hashPayload(map[string]any{
|
||||
"source_service": journal.SourceService,
|
||||
"idempotency_key": journal.IdempotencyKey,
|
||||
"source_transaction_id": journal.SourceTransactionID,
|
||||
"tracking_code": journal.TrackingCode,
|
||||
"effect_kind": journal.EffectKind,
|
||||
"event_version": journal.EventVersion,
|
||||
"entries": entries,
|
||||
"reversal_of": journal.ReversalOfJournalID,
|
||||
"occurred_at": journal.OccurredAt.UTC().Format(time.RFC3339Nano),
|
||||
"correlation_id": journal.CorrelationID,
|
||||
"actor_id": journal.ActorID,
|
||||
"blockchain": journal.Blockchain,
|
||||
"metadata": journal.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
func eventHash(event domain.TransactionEvent) (string, error) {
|
||||
return hashPayload(map[string]any{
|
||||
"source_service": event.SourceService,
|
||||
"idempotency_key": event.IdempotencyKey,
|
||||
"source_transaction_id": event.SourceTransactionID,
|
||||
"tracking_code": event.TrackingCode,
|
||||
"event_version": event.EventVersion,
|
||||
"state": event.State,
|
||||
"error_code": event.ErrorCode,
|
||||
"error_message": event.ErrorMessage,
|
||||
"occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano),
|
||||
"correlation_id": event.CorrelationID,
|
||||
"actor_id": event.ActorID,
|
||||
"blockchain": event.Blockchain,
|
||||
"metadata": event.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
func hashPayload(payload any) (string, error) {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
func newUUID() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(value)
|
||||
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
|
||||
}
|
||||
|
||||
func cloneMetadata(metadata map[string]string) map[string]string {
|
||||
clone := make(map[string]string, len(metadata))
|
||||
for key, value := range metadata {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidArgument, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func encodePageToken(offset int) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset)))
|
||||
}
|
||||
|
||||
func decodePageToken(token string) (int, error) {
|
||||
if token == "" {
|
||||
return 0, nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
offset, err := strconv.Atoi(string(decoded))
|
||||
if err != nil || offset < 0 {
|
||||
return 0, fmt.Errorf("invalid offset")
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
@@ -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