284 lines
7.3 KiB
Go
284 lines
7.3 KiB
Go
package documents
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shopspring/decimal"
|
|
"github.com/tech/sendico/billing/documents/internal/content"
|
|
"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 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"}},
|
|
},
|
|
}
|
|
|
|
svc := NewService(zap.NewNop(), nil, nil,
|
|
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(content.DocumentIntegrityHashPrefix)
|
|
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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestBuildOperationBlocks_CertificateIncludesPaymentData(t *testing.T) {
|
|
snapshot := operationSnapshot{
|
|
OrganizationRef: "org-1",
|
|
GatewayService: "chain_gateway",
|
|
OperationRef: "op-123",
|
|
PaymentRef: "pay-123",
|
|
ClientName: "Jane Customer",
|
|
ClientAddress: "Main Street 1, City",
|
|
OperationCode: "transfer",
|
|
OperationLabel: "Outbound transfer",
|
|
OperationState: "completed",
|
|
Amount: "100.50",
|
|
Currency: "USDT",
|
|
StartedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC),
|
|
CompletedAt: time.Date(2026, 3, 2, 12, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
blocks := buildOperationBlocks(snapshot)
|
|
if len(blocks) == 0 {
|
|
t.Fatalf("expected blocks")
|
|
}
|
|
|
|
if got := blocks[0].Lines[0]; got != content.OperationDocument.Title {
|
|
t.Fatalf("title mismatch: got=%q want=%q", got, content.OperationDocument.Title)
|
|
}
|
|
|
|
meta := findTaggedBlock(blocks, renderer.TagMeta)
|
|
if meta == nil {
|
|
t.Fatalf("expected meta block")
|
|
}
|
|
|
|
metaText := strings.Join(meta.Lines, "\n")
|
|
if !strings.Contains(metaText, "Certificate No.: pay-123") {
|
|
t.Fatalf("meta should include certificate number, got=%q", metaText)
|
|
}
|
|
if !strings.Contains(metaText, "Date: March 2, 2026") {
|
|
t.Fatalf("meta should include certificate date, got=%q", metaText)
|
|
}
|
|
|
|
amountFound := false
|
|
clientFound := false
|
|
for _, block := range blocks {
|
|
if block.Tag != renderer.TagKV {
|
|
continue
|
|
}
|
|
|
|
for _, row := range block.Rows {
|
|
if len(row) >= 2 && row[0] == content.OperationDocument.RowTotalAmount && row[1] == "100.50 USDT" {
|
|
amountFound = true
|
|
}
|
|
if len(row) >= 2 && row[0] == content.OperationDocument.RowClient && row[1] == "Jane Customer" {
|
|
clientFound = true
|
|
}
|
|
}
|
|
}
|
|
if !amountFound {
|
|
t.Fatalf("expected total amount row with payment amount")
|
|
}
|
|
if !clientFound {
|
|
t.Fatalf("expected client row with customer name")
|
|
}
|
|
}
|
|
|
|
func TestCertificateNumber_FallsBackToOperationRef(t *testing.T) {
|
|
got := certificateNumber(operationSnapshot{
|
|
OperationRef: "op-777",
|
|
})
|
|
|
|
if got != "op-777" {
|
|
t.Fatalf("certificateNumber fallback mismatch: got=%q want=%q", got, "op-777")
|
|
}
|
|
}
|
|
|
|
func TestCertificateClientName_Fallback(t *testing.T) {
|
|
if got := certificateClientName(operationSnapshot{}); got != "John Doe" {
|
|
t.Fatalf("certificateClientName fallback mismatch: got=%q want=%q", got, "John Doe")
|
|
}
|
|
}
|
|
|
|
func findTaggedBlock(blocks []renderer.Block, tag renderer.Tag) *renderer.Block {
|
|
for i := range blocks {
|
|
if blocks[i].Tag == tag {
|
|
return &blocks[i]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|