237 lines
5.6 KiB
Go
237 lines
5.6 KiB
Go
package documents
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shopspring/decimal"
|
|
"github.com/tech/sendico/billing/documents/renderer"
|
|
"github.com/tech/sendico/billing/documents/storage"
|
|
"github.com/tech/sendico/billing/documents/storage/model"
|
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type stubRepo struct {
|
|
store storage.DocumentsStore
|
|
}
|
|
|
|
func (s *stubRepo) Ping(_ context.Context) error { return nil }
|
|
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
|
|
|
|
var _ storage.Repository = (*stubRepo)(nil)
|
|
|
|
type stubDocumentsStore struct {
|
|
record *model.DocumentRecord
|
|
updateCalls int
|
|
}
|
|
|
|
func (s *stubDocumentsStore) Create(_ context.Context, record *model.DocumentRecord) error {
|
|
s.record = record
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *stubDocumentsStore) Update(_ context.Context, record *model.DocumentRecord) error {
|
|
s.record = record
|
|
s.updateCalls++
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *stubDocumentsStore) GetByPaymentRef(_ context.Context, _ string) (*model.DocumentRecord, error) {
|
|
return s.record, nil
|
|
}
|
|
|
|
func (s *stubDocumentsStore) ListByPaymentRefs(_ context.Context, _ []string) ([]*model.DocumentRecord, error) {
|
|
return []*model.DocumentRecord{s.record}, nil
|
|
}
|
|
|
|
var _ storage.DocumentsStore = (*stubDocumentsStore)(nil)
|
|
|
|
type memDocStore struct {
|
|
data map[string][]byte
|
|
saveCount int
|
|
loadCount int
|
|
}
|
|
|
|
func newMemDocStore() *memDocStore {
|
|
return &memDocStore{data: map[string][]byte{}}
|
|
}
|
|
|
|
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
|
|
m.saveCount++
|
|
copyData := make([]byte, len(data))
|
|
copy(copyData, data)
|
|
m.data[key] = copyData
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *memDocStore) Load(_ context.Context, key string) ([]byte, error) {
|
|
m.loadCount++
|
|
data := m.data[key]
|
|
copyData := make([]byte, len(data))
|
|
copy(copyData, data)
|
|
|
|
return copyData, nil
|
|
}
|
|
|
|
func (m *memDocStore) Counts() (int, int) {
|
|
return m.saveCount, m.loadCount
|
|
}
|
|
|
|
type stubTemplate struct {
|
|
blocks []renderer.Block
|
|
calls int
|
|
}
|
|
|
|
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
|
s.calls++
|
|
|
|
return s.blocks, nil
|
|
}
|
|
|
|
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
|
snapshot := model.ActSnapshot{
|
|
PaymentID: "PAY-123",
|
|
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
|
ExecutorFullName: "Jane Doe",
|
|
Amount: decimal.RequireFromString("100.00"),
|
|
Currency: "USD",
|
|
}
|
|
|
|
tmpl := &stubTemplate{
|
|
blocks: []renderer.Block{
|
|
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
|
|
{Tag: renderer.TagText, Lines: []string{"Executor: Jane Doe", "Amount: 100 USD"}},
|
|
},
|
|
}
|
|
|
|
cfg := Config{
|
|
Issuer: renderer.Issuer{
|
|
LegalName: "Sendico Ltd",
|
|
LegalAddress: "12 Market Street, London, UK",
|
|
},
|
|
}
|
|
|
|
svc := NewService(zap.NewNop(), nil, nil,
|
|
WithConfig(cfg),
|
|
WithTemplateRenderer(tmpl),
|
|
)
|
|
|
|
pdf1, hash1, err := svc.generateActPDF(snapshot)
|
|
if err != nil {
|
|
t.Fatalf("generateActPDF first call: %v", err)
|
|
}
|
|
|
|
if len(pdf1) == 0 {
|
|
t.Fatalf("expected content on first call")
|
|
}
|
|
|
|
if hash1 == "" {
|
|
t.Fatalf("expected non-empty hash on first call")
|
|
}
|
|
|
|
footerHash := extractFooterHash(pdf1)
|
|
|
|
if footerHash == "" {
|
|
t.Fatalf("expected footer hash in PDF")
|
|
}
|
|
|
|
if hash1 != footerHash {
|
|
t.Fatalf("stored hash mismatch: got %s", hash1)
|
|
}
|
|
|
|
pdf2, hash2, err := svc.generateActPDF(snapshot)
|
|
if err != nil {
|
|
t.Fatalf("generateActPDF second call: %v", err)
|
|
}
|
|
if hash2 == "" {
|
|
t.Fatalf("expected non-empty hash on second call")
|
|
}
|
|
footerHash2 := extractFooterHash(pdf2)
|
|
if footerHash2 == "" {
|
|
t.Fatalf("expected footer hash in second PDF")
|
|
}
|
|
if footerHash2 != hash2 {
|
|
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
|
|
}
|
|
}
|
|
|
|
func extractFooterHash(pdf []byte) string {
|
|
prefix := []byte("Document integrity hash: ")
|
|
idx := bytes.Index(pdf, prefix)
|
|
|
|
if idx == -1 {
|
|
return ""
|
|
}
|
|
|
|
start := idx + len(prefix)
|
|
|
|
end := start
|
|
|
|
for end < len(pdf) && isHexDigit(pdf[end]) {
|
|
end++
|
|
}
|
|
|
|
if end-start != 64 {
|
|
return ""
|
|
}
|
|
|
|
return string(pdf[start:end])
|
|
}
|
|
|
|
func isHexDigit(b byte) bool {
|
|
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
|
|
}
|
|
|
|
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
|
|
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
|
|
Issuer: renderer.Issuer{
|
|
LegalName: "Sendico Ltd",
|
|
},
|
|
}))
|
|
|
|
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
|
OrganizationRef: "org-1",
|
|
GatewayService: "chain_gateway",
|
|
OperationRef: "pay-1:step-1",
|
|
PaymentRef: "pay-1",
|
|
OperationCode: "crypto.transfer",
|
|
OperationLabel: "Outbound transfer",
|
|
OperationState: "completed",
|
|
Amount: "100.50",
|
|
Currency: "USDT",
|
|
StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetOperationDocument failed: %v", err)
|
|
}
|
|
if len(resp.GetContent()) == 0 {
|
|
t.Fatalf("expected non-empty PDF content")
|
|
}
|
|
if got, want := resp.GetMimeType(), "application/pdf"; got != want {
|
|
t.Fatalf("mime_type mismatch: got=%q want=%q", got, want)
|
|
}
|
|
if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want {
|
|
t.Fatalf("filename mismatch: got=%q want=%q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestGetOperationDocument_RequiresOperationRef(t *testing.T) {
|
|
svc := NewService(zap.NewNop(), nil, nil)
|
|
|
|
_, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
|
OrganizationRef: "org-1",
|
|
GatewayService: "chain_gateway",
|
|
})
|
|
if status.Code(err) != codes.InvalidArgument {
|
|
t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err)
|
|
}
|
|
}
|