hardlink/internal/printer/receipt_test.go

281 lines
11 KiB
Go

package printer
import (
"bytes"
"reflect"
"strings"
"testing"
)
type receiptTestChar struct {
value byte
center, bold bool
}
type receiptTestOutput struct {
lines []string
chars []receiptTestChar
feeds, cuts int
}
// readReceiptText understands only the commands emitted by this receipt builder.
func readReceiptText(t *testing.T, data []byte) receiptTestOutput {
t.Helper()
var out receiptTestOutput
var line strings.Builder
var center, bold bool
for i := 0; i < len(data); {
if data[i] == ESC || data[i] == GS {
if i+2 >= len(data) {
t.Fatalf("BuildCardholderReceipt control bytes = %v, want complete command", data[i:])
}
prefix, command, value := data[i], data[i+1], data[i+2]
switch {
case prefix == ESC && command == 'a':
if value != 0 && value != CENTER {
t.Errorf("receipt alignment = %d, want left or center", value)
}
center = value == CENTER
case prefix == ESC && command == 'E':
if value != BOLD_OFF && value != BOLD_ON {
t.Errorf("receipt bold = %d, want on or off", value)
}
bold = value == BOLD_ON
case prefix == GS && command == '!':
if value != NORMAL_FONT {
t.Errorf("receipt size = %d, want normal size", value)
}
case prefix == ESC && command == 'd':
out.feeds++
if value != 7 || center || bold || i != len(data)-6 {
t.Errorf("receipt feed = %d, center=%t bold=%t offset=%d, want final seven-line feed in normal state", value, center, bold, i)
}
case prefix == GS && command == 'V':
out.cuts++
if value != 1 || i != len(data)-3 {
t.Errorf("receipt cut = %d at %d, want final GS V 1", value, i)
}
default:
t.Fatalf("BuildCardholderReceipt command = %v, want supported text command", data[i:i+3])
}
i += 3
continue
}
if data[i] == '\n' {
out.lines = append(out.lines, line.String())
line.Reset()
} else {
line.WriteByte(data[i])
out.chars = append(out.chars, receiptTestChar{value: data[i], center: center, bold: bold})
}
i++
}
if line.Len() != 0 || center || bold || out.feeds != 1 || out.cuts != 1 {
t.Errorf("receipt termination: trailing=%q center=%t bold=%t feeds=%d cuts=%d, want normal state and one feed/cut", line.String(), center, bold, out.feeds, out.cuts)
}
return out
}
func buildReceiptText(t *testing.T, entries []ReceiptEntryXML) receiptTestOutput {
t.Helper()
before := append([]ReceiptEntryXML(nil), entries...)
data, err := BuildCardholderReceipt(entries)
if err != nil {
t.Fatalf("BuildCardholderReceipt(%v) error = %v, want nil", entries, err)
}
if !reflect.DeepEqual(entries, before) {
t.Errorf("BuildCardholderReceipt input = %v, want unchanged %v", entries, before)
}
if !bytes.HasSuffix(data, []byte{ESC, 'd', 7, GS, 'V', 1}) {
t.Errorf("BuildCardholderReceipt suffix = %v, want unchanged feed/cut", data)
}
return readReceiptText(t, data)
}
func sampleReceipt(recipient string) []ReceiptEntryXML {
return []ReceiptEntryXML{
{ReceiptEntryId: "Recipient", Value: recipient},
{ReceiptEntryId: "MerchantName", Value: "Standard Test Merchant"},
{ReceiptEntryId: "Aid", Value: "A0000000031010", Label: "AID"},
{ReceiptEntryId: "CardScheme", Value: "VISA DEBIT"},
{ReceiptEntryId: "PanMasked", Value: "************1133", Label: "Card"},
{ReceiptEntryId: "PanSequence", Value: "01", Label: "PAN Seq No"},
{ReceiptEntryId: "TransactionSource", Value: "ICC"},
{ReceiptEntryId: "TransactionType", Value: "SALE"},
{ReceiptEntryId: "TotalAmount", Value: "GBP120.00", Label: "TOTAL"},
{ReceiptEntryId: "CardholderVerification", Value: "PIN VERIFIED"},
{ReceiptEntryId: "TransactionResult", Value: "APPROVED"},
{ReceiptEntryId: "AuthCode", Value: "D0A35D", Label: "Auth Code"},
{ReceiptEntryId: "AuthReference", Value: "ChipDNA-Sale-20260908144731", Label: "Ref"},
{ReceiptEntryId: "MerchantIdMasked", Value: "******7897", Label: "MID"},
{ReceiptEntryId: "TerminalIdMasked", Value: "****9508", Label: "TID"},
{ReceiptEntryId: "TransactionSeqNumber", Value: "00001059", Label: "Transaction Seq. No"},
{ReceiptEntryId: "AuthDateTime", Value: "08/09/2026 14:48:06"},
{ReceiptEntryId: "RetentionMessage", Value: "Please retain for your records"},
}
}
func TestBuildCardholderReceiptCompleteLayout(t *testing.T) {
for _, recipient := range []string{"CARDHOLDER COPY", "MERCHANT COPY"} {
t.Run(recipient, func(t *testing.T) {
entries := sampleReceipt(recipient)
out := buildReceiptText(t, entries)
// Ignore padding used for left/right placement, not semantic row order.
var rows []string
for _, line := range out.lines {
rows = append(rows, strings.Join(strings.Fields(line), " "))
}
rule := strings.Repeat("-", 44)
want := []string{recipient, "Standard Test Merchant", rule, "VISA DEBIT", "Card: ************1133",
"AID: A0000000031010 PAN Seq No: 01", "ICC SALE", rule, "TOTAL: GBP120.00", "PIN VERIFIED APPROVED", rule,
"Auth Code: D0A35D", "Ref: ChipDNA-Sale-20260908144731", "Transaction Seq. No: 00001059",
"MID: ******7897 TID: ****9508", "08/09/2026 14:48:06", rule, "Please retain for your records"}
if !reflect.DeepEqual(rows, want) {
t.Errorf("BuildCardholderReceipt(%q) rows = %q, want %q", recipient, rows, want)
}
for _, entry := range entries {
if !strings.Contains(strings.Join(out.lines, "\n"), entry.Value) {
t.Errorf("BuildCardholderReceipt(%q) lost %s=%q", recipient, entry.ReceiptEntryId, entry.Value)
}
}
for _, line := range out.lines {
if line == "" || len(line) > 44 {
t.Errorf("BuildCardholderReceipt(%q) row = %q, want compact nonempty row within 44 columns", recipient, line)
}
}
if len(out.lines) > 19 {
t.Errorf("BuildCardholderReceipt(%q) rows = %d, want representative receipt at most 19", recipient, len(out.lines))
}
})
}
}
func TestBuildCardholderReceiptFormattingState(t *testing.T) {
out := buildReceiptText(t, sampleReceipt("CARDHOLDER COPY"))
var plain strings.Builder
for _, char := range out.chars {
plain.WriteByte(char.value)
}
for _, tc := range []struct {
text string
center, bold bool
}{
{"CARDHOLDER COPY", true, true}, {"Standard Test Merchant", true, false},
{"VISA DEBIT", false, false}, {"TOTAL:", false, true}, {"GBP120.00", false, true},
{"PIN VERIFIED", false, false}, {"APPROVED", false, true}, {"Auth Code:", false, false},
{"Please retain for your records", true, false},
} {
start := strings.Index(plain.String(), tc.text)
if start < 0 {
t.Fatalf("receipt text lacks %q", tc.text)
}
for _, char := range out.chars[start : start+len(tc.text)] {
if char.center != tc.center || char.bold != tc.bold {
t.Errorf("receipt style for %q = center:%t bold:%t, want center:%t bold:%t", tc.text, char.center, char.bold, tc.center, tc.bold)
break
}
}
}
}
func TestBuildCardholderReceiptMissingFields(t *testing.T) {
for _, tc := range []struct {
name string
entries []ReceiptEntryXML
want []string
}{
{name: "empty"},
{name: "empty values", entries: []ReceiptEntryXML{{ReceiptEntryId: "Aid", Label: "AID"}, {ReceiptEntryId: "Unknown", Label: "Unused"}}},
{name: "one partner", entries: []ReceiptEntryXML{{ReceiptEntryId: "PanSequence", Value: "02"}}, want: []string{"PAN Seq No: 02"}},
{name: "result only", entries: []ReceiptEntryXML{{ReceiptEntryId: "TransactionResult", Value: "DECLINED"}}, want: []string{"DECLINED"}},
} {
t.Run(tc.name, func(t *testing.T) {
out := buildReceiptText(t, tc.entries)
if !reflect.DeepEqual(out.lines, tc.want) {
t.Errorf("BuildCardholderReceipt(%v) rows = %q, want %q", tc.entries, out.lines, tc.want)
}
})
}
}
func TestBuildCardholderReceiptPreservesRemainingEntries(t *testing.T) {
entries := []ReceiptEntryXML{
{ReceiptEntryId: "AuthCode", Value: ""},
{ReceiptEntryId: "FooBar", Value: "first-extra", Priority: "9"},
{ReceiptEntryId: "AuthCode", Value: "primary", Label: "Provider authorization"},
{ReceiptEntryId: "AuthCode", Value: "duplicate", Label: "Second authorization"},
{ReceiptEntryId: "FooBar", Value: "second-extra", Label: "Foo", Priority: "1"},
{ReceiptEntryId: "RetentionMessage", Value: "Retain this"},
{ReceiptEntryId: "RetentionMessage", Value: "Extra retention"},
{ReceiptEntryId: "HTTPCode", Value: "provider-code"},
{Value: "unlabelled-value"},
}
out := buildReceiptText(t, entries)
want := []string{"Provider authorization: primary", "Foo Bar: first-extra", "Second authorization: duplicate",
"Foo: second-extra", "Extra retention", "HTTP Code: provider-code", "unlabelled-value", strings.Repeat("-", 44), "Retain this"}
if !reflect.DeepEqual(out.lines, want) {
t.Errorf("BuildCardholderReceipt remaining rows = %q, want %q", out.lines, want)
}
}
func TestBuildCardholderReceiptPairBoundaries(t *testing.T) {
for _, tc := range []struct {
name, left, right string
paired bool
}{
{"short", "ICC", "SALE", true},
{"exact width", strings.Repeat("L", 20), strings.Repeat("R", 22), true},
{"overflow", strings.Repeat("L", 21), strings.Repeat("R", 22), false},
{"unicode", "TAP \u00c9", "SALE", false},
{"multiline", "ICC\nFALLBACK", "SALE", false},
{"left missing", "", "SALE", false},
{"right missing", "ICC", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
out := buildReceiptText(t, []ReceiptEntryXML{{ReceiptEntryId: "TransactionSource", Value: tc.left}, {ReceiptEntryId: "TransactionType", Value: tc.right}})
var want []string
if tc.paired {
want = []string{tc.left + strings.Repeat(" ", 44-len(tc.left)-len(tc.right)) + tc.right}
} else {
if tc.left != "" {
want = append(want, strings.Split(tc.left, "\n")...)
}
if tc.right != "" {
want = append(want, tc.right)
}
}
if !reflect.DeepEqual(out.lines, want) {
t.Errorf("BuildCardholderReceipt pair(%q,%q) rows = %q, want %q", tc.left, tc.right, out.lines, want)
}
})
}
}
func TestBuildCardholderReceiptLongValuesAndLabels(t *testing.T) {
ref := strings.Repeat("long-reference-", 8)
verify := "CONSUMER DEVICE VERIFICATION WITH ADDITIONAL PROVIDER TEXT"
out := buildReceiptText(t, []ReceiptEntryXML{
{ReceiptEntryId: "CardholderVerification", Value: verify},
{ReceiptEntryId: "TransactionResult", Value: "DECLINED"},
{ReceiptEntryId: "AuthReference", Label: "Provider reference:", Value: ref},
{ReceiptEntryId: "MerchantIdMasked", Label: strings.Repeat("Merchant", 6), Value: "******7897"},
{ReceiptEntryId: "TerminalIdMasked", Value: "****9508"},
})
want := []string{verify, "DECLINED", strings.Repeat("-", 44), "Provider reference: " + ref,
strings.Repeat("Merchant", 6) + ": ******7897", "TID: ****9508"}
if !reflect.DeepEqual(out.lines, want) {
t.Errorf("BuildCardholderReceipt long fields rows = %q, want %q", out.lines, want)
}
}
func TestBuildCardholderReceiptProviderAmounts(t *testing.T) {
for _, amount := range []string{"GBP120.00", "EUR 42,50", "USD1.23", "amount supplied verbatim", strings.Repeat("9", 50)} {
t.Run(amount, func(t *testing.T) {
out := buildReceiptText(t, []ReceiptEntryXML{{ReceiptEntryId: "TotalAmount", Label: "Provider total", Value: amount}})
if len(out.lines) != 1 || !strings.HasPrefix(out.lines[0], "Provider total:") || !strings.HasSuffix(out.lines[0], amount) {
t.Errorf("BuildCardholderReceipt amount(%q) rows = %q, want provider label and unchanged amount", amount, out.lines)
}
})
}
}