package grpcadapter import ( "context" "errors" "testing" "time" applicationledger "gl/application/ledger" domain "gl/domain/ledger" ledgerv1 "gl/gen/ledger/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) type ledgerRepositoryStub struct { journal domain.Journal } func (r *ledgerRepositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) { journal.RecordedAt = time.Unix(2, 0).UTC() r.journal = journal return domain.AppendResult{JournalID: journal.ID}, nil } func (r *ledgerRepositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) { event.RecordedAt = time.Unix(2, 0).UTC() return event, false, nil } func (r *ledgerRepositoryStub) GetByID(context.Context, string) (domain.Journal, error) { return r.journal, nil } func (r *ledgerRepositoryStub) GetByIdempotencyKey(context.Context, string) (domain.Journal, error) { return r.journal, nil } func (r *ledgerRepositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) { return []domain.Journal{r.journal}, nil } func (r *ledgerRepositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) { return domain.ParseAmount("12.5") } func TestAppendJournalMapsProtoToExactDomainAndBack(t *testing.T) { repository := &ledgerRepositoryStub{} service := applicationledger.NewService(repository, func() (string, error) { return "11111111-1111-4111-8111-111111111111", nil }) handler := NewHandler(nil, service) response, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{ SourceService: "wallet", IdempotencyKey: "wallet:1:internal-transfer:v1", SourceTransactionId: "1", EffectKind: "internal-transfer", EventVersion: 1, OccurredAt: timestamppb.New(time.Unix(1, 0).UTC()), Entries: []*ledgerv1.JournalEntry{ { LineNumber: 1, Account: &ledgerv1.AccountReference{ AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "1", AssetId: 5, }, Amount: "-1.2500", }, { LineNumber: 2, Account: &ledgerv1.AccountReference{ AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "2", AssetId: 5, }, Amount: "1.25", }, }, }) if err != nil { t.Fatal(err) } if got := response.GetJournal().GetEntries()[0].GetAmount(); got != "-1.25" { t.Fatalf("unexpected canonical amount: %q", got) } if response.GetJournal().GetPayloadHash() == "" { t.Fatal("payload hash is missing") } } func TestAppendJournalRejectsMissingTimestamp(t *testing.T) { handler := NewHandler(nil, nil) _, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{}) if status.Code(err) != codes.InvalidArgument { t.Fatalf("expected InvalidArgument, got %v", err) } } func TestRPCErrorMapping(t *testing.T) { for _, testCase := range []struct { err error code codes.Code }{ {err: applicationledger.ErrInvalidArgument, code: codes.InvalidArgument}, {err: domain.ErrNotFound, code: codes.NotFound}, {err: domain.ErrIdempotencyConflict, code: codes.AlreadyExists}, {err: domain.ErrIncompleteJournal, code: codes.FailedPrecondition}, {err: domain.ErrAlreadyReversed, code: codes.FailedPrecondition}, {err: context.Canceled, code: codes.Canceled}, {err: context.DeadlineExceeded, code: codes.DeadlineExceeded}, {err: errors.New("database details must not escape"), code: codes.Internal}, } { if got := status.Code(rpcError(testCase.err)); got != testCase.code { t.Fatalf("rpcError(%v) = %v, want %v", testCase.err, got, testCase.code) } } }