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) } }