Compare commits

...

2 Commits

15 changed files with 1405 additions and 137 deletions

View File

@ -34,7 +34,7 @@ import (
) )
const ( const (
buildVersion = "1.3.3" buildVersion = "v1.3.5"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second pollingFrequency = 8 * time.Second
) )

View File

@ -1,6 +1,7 @@
package dispenser package dispenser
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -22,9 +23,13 @@ const (
// cache freshness for "continuous status" reads (tune as you wish) // cache freshness for "continuous status" reads (tune as you wish)
defaultStatusTTL = 1500 * time.Millisecond defaultStatusTTL = 1500 * time.Millisecond
CardWellEmptyMessage = "Card well is empty"
) )
var ( var (
ErrCardWellEmpty = errors.New(CardWellEmptyMessage)
SerialPort string SerialPort string
Address []byte Address []byte
@ -67,10 +72,9 @@ var (
// Status helpers // Status helpers
// -------------------- // --------------------
func logStatus(statusBytes []byte) { func statusDescription(statusBytes []byte) string {
if len(statusBytes) < 4 { if len(statusBytes) < 4 {
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes)) return fmt.Sprintf("<invalid len=%d>", len(statusBytes))
return
} }
posStatus := []struct { posStatus := []struct {
@ -94,13 +98,58 @@ func logStatus(statusBytes []byte) {
result.WriteString(statusMsg + "; ") result.WriteString(statusMsg + "; ")
} }
} }
log.Infof("Dispenser status: %s", result.String()) return result.String()
}
func logStatus(statusBytes []byte) {
log.Infof("Dispenser status: %s", statusDescription(statusBytes))
} }
func isAtEncoderPosition(statusBytes []byte) bool { func isAtEncoderPosition(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x33 return len(statusBytes) >= 4 && statusBytes[3] == 0x33
} }
func validateDispenserStatusData(statusBytes []byte) error {
if len(statusBytes) != 4 {
return fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(statusBytes))
}
statusMaps := []map[byte]string{statusPos0, statusPos1, statusPos2, statusPos3}
for position, mapper := range statusMaps {
if _, ok := mapper[statusBytes[position]]; !ok {
return fmt.Errorf("unknown dispenser status 0x%X at position %d", statusBytes[position], position+1)
}
}
return nil
}
func dispenserStatusError(statusBytes []byte) error {
switch statusBytes[0] {
case 0x34, 0x32, 0x36:
return fmt.Errorf("dispenser error: %s", statusPos0[statusBytes[0]])
}
switch statusBytes[1] {
case 0x32, 0x31:
return fmt.Errorf("dispenser error: %s", statusPos1[statusBytes[1]])
}
switch statusBytes[2] {
case 0x34, 0x32:
return fmt.Errorf("dispenser error: %s", statusPos2[statusBytes[2]])
}
return nil
}
func isPreparationMoving(statusBytes []byte) bool {
return len(statusBytes) == 4 &&
(statusBytes[0] == 0x31 || statusBytes[1] == 0x38)
}
func hasPreparationDiagnostics(statusBytes []byte) bool {
return len(statusBytes) == 4 &&
(statusBytes[0] != 0x30 || statusBytes[1] != 0x30 || statusBytes[2] != 0x30)
}
func stockTake(statusBytes []byte) string { func stockTake(statusBytes []byte) string {
if len(statusBytes) < 4 { if len(statusBytes) < 4 {
return "" return ""

View File

@ -30,12 +30,25 @@ type cmdResp struct {
err error err error
} }
type sequenceTiming struct {
now func() time.Time
wait func(context.Context, time.Duration) error
}
const (
sequencePollInterval = time.Second
sequenceRetryAfter = 6 * time.Second
sequenceTimeout = 12 * time.Second
)
type Client struct { type Client struct {
port *serial.Port port *serial.Port
reqCh chan cmdReq reqCh chan cmdReq
done chan struct{} done chan struct{}
sequenceTiming sequenceTiming
// status cache // status cache
mu sync.RWMutex mu sync.RWMutex
lastStatus []byte lastStatus []byte
@ -54,15 +67,31 @@ func NewClient(port *serial.Port, queueSize int) *Client {
queueSize = 16 queueSize = 16
} }
c := &Client{ c := &Client{
port: port, port: port,
reqCh: make(chan cmdReq, queueSize), reqCh: make(chan cmdReq, queueSize),
done: make(chan struct{}), done: make(chan struct{}),
sequenceTiming: sequenceTiming{
now: time.Now,
wait: waitForSequence,
},
statusTTL: defaultStatusTTL, statusTTL: defaultStatusTTL,
} }
go c.loop() go c.loop()
return c return c
} }
func waitForSequence(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Client) Close() { func (c *Client) Close() {
select { select {
case <-c.done: case <-c.done:
@ -268,113 +297,152 @@ func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
return stockStatus, nil return stockStatus, nil
} }
func (c *Client) DispenserStart(ctx context.Context) (string, error) { func (c *Client) readSequenceStatus(ctx context.Context, operation string) ([]byte, string, error) {
const funcName = "DispenserStart" status, err := c.do(ctx, cmdStatus)
stockStatus := ""
status, err := c.CheckStatus(ctx)
if err != nil { if err != nil {
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err) if ctxErr := ctx.Err(); ctxErr != nil {
return nil, "", fmt.Errorf("[%s] read status: %w", operation, ctxErr)
}
return nil, "", fmt.Errorf("[%s] read status: %w", operation, err)
} }
defer func() { stockStatus := ""
if len(status) == 4 {
stockStatus = stockTake(status)
c.setStock(status)
logStatus(status) logStatus(status)
}() }
stockStatus = stockTake(status) return status, stockStatus, nil
c.setStock(status) }
if isCardWellEmpty(status) { func preparationStatus(operation string, status []byte) (bool, error) {
return stockStatus, fmt.Errorf(stockStatus) if len(status) != 4 {
return false, fmt.Errorf("[%s] %w", operation, validateDispenserStatusData(status))
} }
if isAtEncoderPosition(status) { if isAtEncoderPosition(status) {
if hasPreparationDiagnostics(status) {
log.Warnf(
"[%s] card confirmed at encoder with dispenser diagnostics: %s raw status: % X",
operation,
statusDescription(status),
status,
)
}
return true, nil
}
if err := validateDispenserStatusData(status); err != nil {
return false, fmt.Errorf("[%s] %w", operation, err)
}
if isCardWellEmpty(status) {
return false, fmt.Errorf("[%s] %w", operation, ErrCardWellEmpty)
}
if isPreparationMoving(status) {
return false, nil
}
if err := dispenserStatusError(status); err != nil {
return false, fmt.Errorf("[%s] %w", operation, err)
}
return false, nil
}
func (c *Client) pollForEncoderPosition(
ctx context.Context,
operation string,
retryCommand func(context.Context) error,
) (string, error) {
started := c.sequenceTiming.now()
halfway := started.Add(sequenceRetryAfter)
deadline := started.Add(sequenceTimeout)
retried := false
stockStatus := ""
for {
if err := ctx.Err(); err != nil {
return stockStatus, fmt.Errorf("[%s] %w", operation, err)
}
now := c.sequenceTiming.now()
if !now.Before(deadline) {
return stockStatus, fmt.Errorf("[%s] timed out after %s", operation, sequenceTimeout)
}
status, currentStockStatus, err := c.readSequenceStatus(ctx, operation)
stockStatus = currentStockStatus
if err != nil {
return stockStatus, err
}
ready, err := preparationStatus(operation, status)
if err != nil {
return stockStatus, err
}
if ready {
return stockStatus, nil
}
now = c.sequenceTiming.now()
if !now.Before(deadline) {
return stockStatus, fmt.Errorf("[%s] timed out after %s", operation, sequenceTimeout)
}
if retryCommand != nil && !retried && !now.Before(halfway) {
if err := retryCommand(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] retry command: %w", operation, err)
}
retried = true
}
wait := sequencePollInterval
if remaining := deadline.Sub(now); remaining < wait {
wait = remaining
}
if err := c.sequenceTiming.wait(ctx, wait); err != nil {
return stockStatus, fmt.Errorf("[%s] %w", operation, err)
}
}
}
func (c *Client) prepareCardAtEncoder(ctx context.Context, operation string) (string, error) {
status, stockStatus, err := c.readSequenceStatus(ctx, operation)
if err != nil {
return stockStatus, err
}
ready, err := preparationStatus(operation, status)
if err != nil {
return stockStatus, err
}
if ready {
return stockStatus, nil return stockStatus, nil
} }
if err := c.ToEncoder(ctx); err != nil { if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err) return stockStatus, fmt.Errorf("[%s] to encoder: %w", operation, err)
}
halfway := time.Now().Add(6 * time.Second)
deadline := time.Now().Add(12 * time.Second)
for {
time.Sleep(delay * 2)
switch {
case time.Now().After(halfway):
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
case time.Now().After(deadline):
return stockStatus, nil
}
status, _ = c.do(ctx, cmdStatus)
stockStatus = stockTake(status)
c.setStock(status)
logStatus(status)
// error states first
if isCardWellEmpty(status) {
return stockStatus, fmt.Errorf(stockStatus)
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
} }
return c.pollForEncoderPosition(ctx, operation, c.ToEncoder)
} }
func (c *Client) DispenserFinal(ctx context.Context) (string, error) { // PrepareCurrentCard authoritatively places the card to be encoded at the encoder.
const funcName = "DispenserFinal" func (c *Client) PrepareCurrentCard(ctx context.Context) (string, error) {
stockStatus := "" return c.prepareCardAtEncoder(ctx, "PrepareCurrentCard")
var status []byte }
// DeliverCurrentCard presents the encoded card and confirms command acceptance.
func (c *Client) DeliverCurrentCard(ctx context.Context) (string, error) {
const operation = "DeliverCurrentCard"
if err := c.OutOfMouth(ctx); err != nil { if err := c.OutOfMouth(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err) return "", fmt.Errorf("[%s] out of mouth: %w", operation, err)
}
time.Sleep(delay)
status, err := c.do(ctx, cmdStatus)
if err == nil && len(status) >= 4 {
c.setStock(status)
}
time.Sleep(delay)
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
defer func() {
logStatus(status)
}()
halfway := time.Now().Add(6 * time.Second)
deadline := time.Now().Add(12 * time.Second)
for {
time.Sleep(delay * 2)
switch {
case time.Now().After(halfway):
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
case time.Now().After(deadline):
return stockStatus, nil
}
status, _ = c.do(ctx, cmdStatus)
stockStatus = stockTake(status)
c.setStock(status)
logStatus(status)
if isCardWellEmpty(status) {
return stockStatus, nil
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
} }
return "", nil
}
// BeginPrepareNextCard starts moving the next card to the encoder without waiting for readiness.
func (c *Client) BeginPrepareNextCard(ctx context.Context) error {
if err := c.ToEncoder(ctx); err != nil {
return fmt.Errorf("[BeginPrepareNextCard] to encoder: %w", err)
}
return nil
}
// PrepareNextCard places a new card at the encoder for a later issuance attempt.
func (c *Client) PrepareNextCard(ctx context.Context) (string, error) {
return c.prepareCardAtEncoder(ctx, "PrepareNextCard")
} }

View File

@ -0,0 +1,404 @@
package dispenser
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
log "github.com/sirupsen/logrus"
)
type fakeSequenceDevice struct {
statusResponses []cmdResp
commandErrors map[cmdType][]error
commands []cmdType
}
func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *fakeSequenceDevice) {
t.Helper()
device := &fakeSequenceDevice{
statusResponses: append([]cmdResp(nil), statusResponses...),
commandErrors: make(map[cmdType][]error),
}
now := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
client := &Client{
reqCh: make(chan cmdReq),
done: make(chan struct{}),
sequenceTiming: sequenceTiming{
now: func() time.Time {
return now
},
wait: func(ctx context.Context, duration time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
now = now.Add(duration)
return nil
}
},
},
}
go func() {
for {
select {
case <-client.done:
return
case request := <-client.reqCh:
device.commands = append(device.commands, request.typ)
if request.typ == cmdStatus {
if len(device.statusResponses) == 0 {
request.respCh <- cmdResp{err: errors.New("unexpected status read")}
continue
}
response := device.statusResponses[0]
device.statusResponses = device.statusResponses[1:]
request.respCh <- response
continue
}
var err error
if queued := device.commandErrors[request.typ]; len(queued) > 0 {
err = queued[0]
device.commandErrors[request.typ] = queued[1:]
}
request.respCh <- cmdResp{err: err}
}
}
}()
t.Cleanup(client.Close)
return client, device
}
func status(position byte) []byte {
return []byte{0x30, 0x30, 0x30, position}
}
func commandCount(commands []cmdType, target cmdType) int {
count := 0
for _, command := range commands {
if command == target {
count++
}
}
return count
}
func TestPrepareCurrentCardAcceptsEncoderPositionWithStaleDiagnostics(t *testing.T) {
tests := []struct {
name string
status []byte
wantStock string
}{
{
name: "dispense error and jam",
status: []byte{0x30, 0x32, 0x32, 0x33},
wantStock: "Card jammed",
},
{
name: "supply diagnostics",
status: []byte{0x32, 0x30, 0x31, 0x33},
wantStock: "Card pre-empty",
},
{
name: "combined stale jam and supply diagnostics",
status: []byte{0x30, 0x32, 0x33, 0x33},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var logOutput bytes.Buffer
standardLogger := log.StandardLogger()
previousOutput := standardLogger.Out
standardLogger.SetOutput(&logOutput)
t.Cleanup(func() {
standardLogger.SetOutput(previousOutput)
})
client, device := newSequenceTestClient(t, cmdResp{status: test.status})
stock, err := client.PrepareCurrentCard(context.Background())
if err != nil {
t.Fatal(err)
}
if stock != test.wantStock {
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
}
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
t.Fatalf("to-encoder commands = %d, want 0", got)
}
if got := commandCount(device.commands, cmdStatus); got != 1 {
t.Fatalf("status reads = %d, want 1", got)
}
logged := logOutput.String()
if !strings.Contains(logged, "card confirmed at encoder") ||
!strings.Contains(logged, statusDescription(test.status)) ||
!strings.Contains(logged, "raw status") {
t.Fatalf("encoder diagnostic log = %q", logged)
}
})
}
}
func TestPrepareCurrentCardPollingAllowsTransientMovementWithStaleErrors(t *testing.T) {
client, device := newSequenceTestClient(t,
cmdResp{status: status(0x34)},
cmdResp{status: []byte{0x31, 0x38, 0x32, 0x30}},
cmdResp{status: []byte{0x30, 0x32, 0x32, 0x33}},
)
stock, err := client.PrepareCurrentCard(context.Background())
if err != nil {
t.Fatal(err)
}
if stock != "Card jammed" {
t.Fatalf("stock status = %q, want Card jammed", stock)
}
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
t.Fatalf("to-encoder commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 3 {
t.Fatalf("status reads = %d, want 3", got)
}
}
func TestPrepareCurrentCardRejectsFailureWithoutEncoderOrMovement(t *testing.T) {
tests := []struct {
name string
response cmdResp
wantError string
wantStock string
}{
{
name: "status read error",
response: cmdResp{err: errors.New("serial read failed")},
wantError: "serial read failed",
},
{
name: "malformed status",
response: cmdResp{status: []byte{0x30, 0x30, 0x31}},
wantError: "malformed dispenser status",
},
{
name: "unknown status",
response: cmdResp{status: []byte{0x30, 0x30, 0x30, 0x39}},
wantError: "unknown dispenser status",
},
{
name: "jammed preparation",
response: cmdResp{status: []byte{0x30, 0x30, 0x32, 0x34}},
wantError: "Card jammed",
wantStock: "Card jammed",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client, _ := newSequenceTestClient(t, test.response)
stock, err := client.PrepareCurrentCard(context.Background())
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want containing %q", err, test.wantError)
}
if stock != test.wantStock {
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
}
})
}
}
func TestPrepareCurrentCardReturnsAuthoritativeEmptyWellError(t *testing.T) {
client, device := newSequenceTestClient(t, cmdResp{status: status(0x38)})
stock, err := client.PrepareCurrentCard(context.Background())
if !errors.Is(err, ErrCardWellEmpty) {
t.Fatalf("error = %v, want ErrCardWellEmpty", err)
}
if stock != "Card empty" {
t.Fatalf("stock status = %q, want Card empty", stock)
}
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
t.Fatalf("to-encoder commands = %d, want 0", got)
}
}
func TestDeliverCurrentCardSucceedsAfterCommandAcceptanceWithoutStatusPolling(t *testing.T) {
staleStatus := []byte{0x30, 0x32, 0x32, 0x33}
client, device := newSequenceTestClient(t, cmdResp{status: staleStatus})
stock, err := client.DeliverCurrentCard(context.Background())
if err != nil {
t.Fatal(err)
}
if stock != "" {
t.Fatalf("stock status = %q, want empty", stock)
}
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
t.Fatalf("out-of-mouth commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
if len(device.statusResponses) != 1 {
t.Fatalf("queued status responses = %d, want stale status unread", len(device.statusResponses))
}
}
func TestDeliverCurrentCardRejectsCommandFailures(t *testing.T) {
tests := []struct {
name string
err error
}{
{name: "serial dispatch failure", err: errors.New("serial write failed")},
{name: "invalid acknowledgement", err: errors.New("unexpected response status")},
{name: "missing acknowledgement", err: errors.New("no response from dispenser")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client, device := newSequenceTestClient(t)
device.commandErrors[cmdOutOfMouth] = []error{test.err}
_, err := client.DeliverCurrentCard(context.Background())
if err == nil || !strings.Contains(err.Error(), test.err.Error()) {
t.Fatalf("error = %v, want containing %q", err, test.err)
}
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
t.Fatalf("out-of-mouth commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
})
}
}
func TestDeliverCurrentCardPreservesContextCancellation(t *testing.T) {
client, _ := newSequenceTestClient(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.DeliverCurrentCard(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func TestPrepareCurrentCardRetriesOnceThenTimesOut(t *testing.T) {
responses := make([]cmdResp, 13)
for index := range responses {
responses[index] = cmdResp{status: status(0x34)}
}
client, device := newSequenceTestClient(t, responses...)
_, err := client.PrepareCurrentCard(context.Background())
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("error = %v, want preparation timeout", err)
}
if got := commandCount(device.commands, cmdToEncoder); got != 2 {
t.Fatalf("to-encoder commands = %d, want initial command plus one halfway retry", got)
}
if got := commandCount(device.commands, cmdStatus); got != 13 {
t.Fatalf("status reads = %d, want 13", got)
}
}
func TestPrepareCurrentCardPropagatesContextCancellation(t *testing.T) {
client, _ := newSequenceTestClient(t,
cmdResp{status: status(0x34)},
cmdResp{status: status(0x34)},
)
ctx, cancel := context.WithCancel(context.Background())
client.sequenceTiming.wait = func(context.Context, time.Duration) error {
cancel()
return ctx.Err()
}
_, err := client.PrepareCurrentCard(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func TestBeginPrepareNextCardDispatchesWithoutReadinessPolling(t *testing.T) {
client, device := newSequenceTestClient(t)
if err := client.BeginPrepareNextCard(context.Background()); err != nil {
t.Fatal(err)
}
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
t.Fatalf("to-encoder commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
}
func TestBeginPrepareNextCardReturnsDispatchFailureWithoutPolling(t *testing.T) {
client, device := newSequenceTestClient(t)
device.commandErrors[cmdToEncoder] = []error{errors.New("dispatch failed")}
err := client.BeginPrepareNextCard(context.Background())
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
t.Fatalf("error = %v, want dispatch failure", err)
}
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
t.Fatalf("to-encoder commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
}
func TestPrepareCardAtEncoderRequiresConfirmedEncoderState(t *testing.T) {
tests := []struct {
name string
responses []cmdResp
wantError string
wantToEncoder int
}{
{
name: "already at encoder",
responses: []cmdResp{{status: status(0x33)}},
wantToEncoder: 0,
},
{
name: "moves ready card to encoder",
responses: []cmdResp{
{status: status(0x34)},
{status: status(0x33)},
},
wantToEncoder: 1,
},
{
name: "empty card well",
responses: []cmdResp{{status: status(0x38)}},
wantError: CardWellEmptyMessage,
wantToEncoder: 0,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client, device := newSequenceTestClient(t, test.responses...)
_, err := client.PrepareNextCard(context.Background())
if test.wantError == "" && err != nil {
t.Fatal(err)
}
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
t.Fatalf("error = %v, want containing %q", err, test.wantError)
}
if got := commandCount(device.commands, cmdToEncoder); got != test.wantToEncoder {
t.Fatalf("to-encoder commands = %d, want %d", got, test.wantToEncoder)
}
})
}
}

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -35,6 +36,20 @@ type Client struct {
httpClient *http.Client httpClient *http.Client
} }
const (
dojoTerminalUnavailableStatus = "TERMINAL_UNAVAILABLE"
dojoTerminalUnavailableMessage = "Payment terminal is unavailable"
)
type httpResponseError struct {
StatusCode int
Body string
}
func (e *httpResponseError) Error() string {
return fmt.Sprintf("Dojo returned HTTP %d: %s", e.StatusCode, e.Body)
}
type terminalSessionWithUpdates struct { type terminalSessionWithUpdates struct {
terminalSessionResponse terminalSessionResponse
@ -103,6 +118,21 @@ func (c *Client) Sale(
session, err := c.createTerminalSession(ctx, intent.ID) session, err := c.createTerminalSession(ctx, intent.ID)
if err != nil { if err != nil {
var responseErr *httpResponseError
if errors.As(err, &responseErr) && responseErr.StatusCode == http.StatusConflict {
log.WithFields(log.Fields{
"provider": "Dojo",
"operation": "createTerminalSession",
"status": responseErr.StatusCode,
"terminal_unavailable": true,
}).Warn(dojoTerminalUnavailableMessage)
sendPaymentStatus(onStatus, paymentstatus.TerminalUnavailable)
result := c.baseResult(req, nil)
result.Status = dojoTerminalUnavailableStatus
result.ErrorMessage = dojoTerminalUnavailableMessage
return result, nil
}
return nil, err return nil, err
} }
@ -569,10 +599,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, payload any, t
if err != nil { if err != nil {
return fmt.Errorf("read response: %w", err) return fmt.Errorf("read response: %w", err)
} }
// log.Println("Dojo payment result raw:", string(responseBody))
if resp.StatusCode < http.StatusOK || if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices { resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) return &httpResponseError{
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(responseBody)),
}
} }
if target == nil || len(responseBody) == 0 { if target == nil || len(responseBody) == 0 {

View File

@ -0,0 +1,199 @@
package dojo
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
const terminalUnavailableBody = `{"detail":"the terminal is either offline or currently in use","errors":{},"status":409,"title":"terminal unavailable","traceId":"trace-secret","type":"https://docs.dojo.tech/problems/terminal-unavailable"}`
func TestDoJSONReturnsStructuredHTTPResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(" " + terminalUnavailableBody + " "))
}))
defer server.Close()
client := newDojoTestClient(t, server)
err := client.doJSON(context.Background(), http.MethodPost, "/payment-intents", nil, false, nil)
var responseErr *httpResponseError
if !errors.As(err, &responseErr) {
t.Fatalf("error = %T %v, want *httpResponseError", err, err)
}
if responseErr.StatusCode != http.StatusConflict {
t.Fatalf("StatusCode = %d, want %d", responseErr.StatusCode, http.StatusConflict)
}
if responseErr.Body != terminalUnavailableBody {
t.Fatalf("Body = %q, want retained trimmed response", responseErr.Body)
}
}
func TestSaleMapsCreateTerminalSessionConflictToTerminalUnavailable(t *testing.T) {
var paymentIntentRequests atomic.Int32
var terminalSessionRequests atomic.Int32
var unexpectedRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/payment-intents":
paymentIntentRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
case r.Method == http.MethodPost && r.URL.Path == "/terminal-sessions":
terminalSessionRequests.Add(1)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
default:
unexpectedRequests.Add(1)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}
}))
defer server.Close()
client := newDojoTestClient(t, server)
request := paymentsvc.SaleRequest{
RequestID: "REQ-123",
Reference: "BOOKING-123",
Amount: 10852,
Currency: "GBP",
}
var statuses []string
result, err := client.Sale(context.Background(), request, func(update paymentsvc.StatusUpdate) {
statuses = append(statuses, update.Code)
})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("Sale returned nil result")
}
if result.Success || result.Status == "APPROVED" {
t.Fatalf("result was accidentally successful: %+v", result)
}
if result.Status != dojoTerminalUnavailableStatus || result.ErrorMessage != dojoTerminalUnavailableMessage {
t.Fatalf("terminal-unavailable result = %+v", result)
}
if result.RequestID != request.RequestID ||
result.Operation != "SALE" ||
result.Amount != request.Amount ||
result.Currency != request.Currency ||
result.DeviceUsed != "terminal-1" ||
result.DeviceType != "Dojo Terminal" {
t.Fatalf("base result fields were not retained: %+v", result)
}
if result.TransactionID != "" || result.CustomerReceipt != "" || result.MerchantReceipt != "" {
t.Fatalf("terminal-unavailable result invented transaction data: %+v", result)
}
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting, paymentstatus.TerminalUnavailable}) {
t.Fatalf("statuses = %#v", statuses)
}
if paymentIntentRequests.Load() != 1 || terminalSessionRequests.Load() != 1 || unexpectedRequests.Load() != 0 {
t.Fatalf(
"requests: intent=%d terminal=%d unexpected=%d",
paymentIntentRequests.Load(),
terminalSessionRequests.Load(),
unexpectedRequests.Load(),
)
}
resultText := fmt.Sprintf("%+v", result)
for _, forbidden := range []string{"offline or currently in use", "trace-secret", "docs.dojo.tech", terminalUnavailableBody} {
if strings.Contains(resultText, forbidden) {
t.Fatalf("result exposed %q: %s", forbidden, resultText)
}
}
}
func TestSaleDoesNotClassifyPaymentIntentConflictAsTerminalUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/payment-intents" {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
}))
defer server.Close()
client := newDojoTestClient(t, server)
var statuses []string
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
RequestID: "REQ-123",
Amount: 10852,
Currency: "GBP",
}, func(update paymentsvc.StatusUpdate) {
statuses = append(statuses, update.Code)
})
if result != nil || err == nil {
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
}
var responseErr *httpResponseError
if !errors.As(err, &responseErr) || responseErr.StatusCode != http.StatusConflict {
t.Fatalf("error = %T %v, want wrapped HTTP 409", err, err)
}
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting}) {
t.Fatalf("statuses = %#v, want only PAYMENT_STARTING", statuses)
}
}
func TestSaleRetainsGenericCreateTerminalSessionErrors(t *testing.T) {
for _, statusCode := range []int{http.StatusBadRequest, http.StatusBadGateway} {
t.Run(http.StatusText(statusCode), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/payment-intents":
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
case "/terminal-sessions":
w.WriteHeader(statusCode)
_, _ = fmt.Fprintln(w, `{"detail":"provider failure"}`)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := newDojoTestClient(t, server)
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
RequestID: "REQ-123",
Amount: 10852,
Currency: "GBP",
}, nil)
if result != nil || err == nil {
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
}
var responseErr *httpResponseError
if !errors.As(err, &responseErr) || responseErr.StatusCode != statusCode {
t.Fatalf("error = %T %v, want wrapped HTTP %d", err, err, statusCode)
}
})
}
}
func newDojoTestClient(t *testing.T, server *httptest.Server) *Client {
t.Helper()
client, err := NewClient(Config{
BaseURL: server.URL,
APIKey: "test-api-key",
SoftwareHouseID: "software-house-1",
TerminalID: "terminal-1",
})
if err != nil {
t.Fatal(err)
}
client.httpClient = server.Client()
return client
}

View File

@ -0,0 +1,313 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
log "github.com/sirupsen/logrus"
)
type dispenserCallResult struct {
status string
err error
}
type fakeDoorCardDispenser struct {
prepareCurrent dispenserCallResult
deliverCurrent dispenserCallResult
beginNextErr error
beginNext func() error
prepareNext dispenserCallResult
calls []string
}
func (d *fakeDoorCardDispenser) PrepareCurrentCard(context.Context) (string, error) {
d.calls = append(d.calls, "prepare current")
return d.prepareCurrent.status, d.prepareCurrent.err
}
func (d *fakeDoorCardDispenser) DeliverCurrentCard(context.Context) (string, error) {
d.calls = append(d.calls, "deliver current")
return d.deliverCurrent.status, d.deliverCurrent.err
}
func (d *fakeDoorCardDispenser) BeginPrepareNextCard(context.Context) error {
d.calls = append(d.calls, "begin prepare next")
if d.beginNext != nil {
return d.beginNext()
}
return d.beginNextErr
}
func (d *fakeDoorCardDispenser) PrepareNextCard(context.Context) (string, error) {
d.calls = append(d.calls, "prepare next")
return d.prepareNext.status, d.prepareNext.err
}
type fakeDoorCardLockServer struct {
sequenceErr error
buildCalls int
sequenceCalls int
}
func (l *fakeDoorCardLockServer) BuildCommand(lockserver.DoorCardRequest, time.Time, time.Time) error {
l.buildCalls++
return nil
}
func (l *fakeDoorCardLockServer) LockSequence() error {
l.sequenceCalls++
return l.sequenceErr
}
func performIssueDoorCardRequest(
t *testing.T,
dispenser *fakeDoorCardDispenser,
lock *fakeDoorCardLockServer,
) (*httptest.ResponseRecorder, cmstypes.StatusRec, *App) {
t.Helper()
payload := lockserver.DoorCardRequest{
RoomField: "101",
CheckinTime: "2026-07-23 15:00:00 +0100",
CheckoutTime: "2026-07-24 11:00:00 +0100",
FollowStr: "0",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
app := &App{
disp: dispenser,
lockserver: lock,
cfg: &config.ConfigRec{},
}
request := httptest.NewRequest(http.MethodPost, "/issuedoorcard", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
app.issueDoorCard(recorder, request)
var response cmstypes.StatusRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response %q: %v", recorder.Body.String(), err)
}
return recorder, response, app
}
func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
encodingErr := errors.New("key encoding failed")
tests := []struct {
name string
dispenser fakeDoorCardDispenser
lockErr error
wantHTTP int
wantMessage string
wantCalls []string
wantLockSequence int
wantCardWell string
}{
{
name: "initial dispenser preparation failure is unavailable",
dispenser: fakeDoorCardDispenser{
prepareCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("card jammed")},
},
wantHTTP: http.StatusServiceUnavailable,
wantMessage: "Dispense error: card jammed",
wantCalls: []string{"prepare current"},
wantCardWell: "Card jammed",
},
{
name: "initial empty card well has stable message",
dispenser: fakeDoorCardDispenser{
prepareCurrent: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
},
wantHTTP: http.StatusServiceUnavailable,
wantMessage: dispenser.CardWellEmptyMessage,
wantCalls: []string{"prepare current"},
wantCardWell: "Card empty",
},
{
name: "encoding and accepted delivery command succeed",
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
},
{
name: "successful encoding with delivery command failure is unavailable",
dispenser: fakeDoorCardDispenser{
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
},
wantHTTP: http.StatusServiceUnavailable,
wantMessage: "Card delivery could not be confirmed",
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
wantCardWell: "Card jammed",
},
{
name: "failed next-card dispatch does not undo delivered card",
dispenser: fakeDoorCardDispenser{
beginNextErr: errors.New("dispatch failed"),
},
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
},
{
name: "encoding failure with safe recovery remains retryable",
lockErr: encodingErr,
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current", "prepare next"},
wantLockSequence: 1,
},
{
name: "encoding failure with failed-card delivery failure is unavailable",
dispenser: fakeDoorCardDispenser{
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
},
lockErr: encodingErr,
wantHTTP: http.StatusServiceUnavailable,
wantMessage: "Dispenser recovery failed; another encoding attempt is not safe",
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
wantCardWell: "Card jammed",
},
{
name: "encoding failure with empty card well has stable message",
dispenser: fakeDoorCardDispenser{
prepareNext: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
},
lockErr: encodingErr,
wantHTTP: http.StatusServiceUnavailable,
wantMessage: dispenser.CardWellEmptyMessage,
wantCalls: []string{"prepare current", "deliver current", "prepare next"},
wantLockSequence: 1,
wantCardWell: "Card empty",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dispenser := test.dispenser
lock := &fakeDoorCardLockServer{sequenceErr: test.lockErr}
recorder, response, app := performIssueDoorCardRequest(t, &dispenser, lock)
if recorder.Code != test.wantHTTP {
t.Fatalf("HTTP status = %d, want %d", recorder.Code, test.wantHTTP)
}
if response.Code != test.wantHTTP {
t.Fatalf("response code = %d, want %d", response.Code, test.wantHTTP)
}
if response.Message != test.wantMessage {
t.Fatalf("response message = %q, want %q", response.Message, test.wantMessage)
}
if !reflect.DeepEqual(dispenser.calls, test.wantCalls) {
t.Fatalf("dispenser calls = %#v, want %#v", dispenser.calls, test.wantCalls)
}
if lock.sequenceCalls != test.wantLockSequence {
t.Fatalf("lock sequence calls = %d, want %d", lock.sequenceCalls, test.wantLockSequence)
}
if test.wantLockSequence > 0 && lock.buildCalls != 1 {
t.Fatalf("build command calls = %d, want 1", lock.buildCalls)
}
if test.wantLockSequence == 0 && lock.buildCalls != 0 {
t.Fatalf("build command calls = %d, want 0", lock.buildCalls)
}
if got := app.CardWellStatus(); got != test.wantCardWell {
t.Fatalf("card-well status = %q, want %q", got, test.wantCardWell)
}
})
}
}
type responseTrackingRecorder struct {
*httptest.ResponseRecorder
wroteResponse bool
}
func (r *responseTrackingRecorder) WriteHeader(statusCode int) {
r.wroteResponse = true
r.ResponseRecorder.WriteHeader(statusCode)
}
func (r *responseTrackingRecorder) Write(body []byte) (int, error) {
r.wroteResponse = true
return r.ResponseRecorder.Write(body)
}
func TestIssueDoorCardDispatchesNextCardBeforeHTTP200(t *testing.T) {
var recorder *responseTrackingRecorder
dispatchCalled := false
dispenser := &fakeDoorCardDispenser{
beginNext: func() error {
dispatchCalled = true
if recorder.wroteResponse {
t.Error("HTTP response started before next-card preparation was dispatched")
}
return nil
},
}
lock := &fakeDoorCardLockServer{}
payload := lockserver.DoorCardRequest{
RoomField: "101",
CheckinTime: "2026-07-23 15:00:00 +0100",
CheckoutTime: "2026-07-24 11:00:00 +0100",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
app := &App{disp: dispenser, lockserver: lock, cfg: &config.ConfigRec{}}
request := httptest.NewRequest(http.MethodPost, "/issuedoorcard", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder = &responseTrackingRecorder{ResponseRecorder: httptest.NewRecorder()}
app.issueDoorCard(recorder, request)
if !dispatchCalled {
t.Fatal("next-card preparation was not dispatched")
}
if recorder.Code != http.StatusOK {
t.Fatalf("HTTP status = %d, want %d", recorder.Code, http.StatusOK)
}
}
func TestIssueDoorCardLogsNextCardDispatchFailureAndStillSucceeds(t *testing.T) {
var logOutput bytes.Buffer
standardLogger := log.StandardLogger()
previousOutput := standardLogger.Out
standardLogger.SetOutput(&logOutput)
t.Cleanup(func() {
standardLogger.SetOutput(previousOutput)
})
dispenser := &fakeDoorCardDispenser{beginNextErr: errors.New("dispatch failed")}
lock := &fakeDoorCardLockServer{}
recorder, _, _ := performIssueDoorCardRequest(t, dispenser, lock)
if recorder.Code != http.StatusOK {
t.Fatalf("HTTP status = %d, want %d", recorder.Code, http.StatusOK)
}
logged := logOutput.String()
if !strings.Contains(logged, "dispatch failed") ||
!strings.Contains(logged, "Next card preparation dispatch") {
t.Fatalf("dispatch failure log = %q", logged)
}
}

View File

@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"errors"
"fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@ -25,8 +27,15 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
type doorCardDispenser interface {
PrepareCurrentCard(context.Context) (string, error)
DeliverCurrentCard(context.Context) (string, error)
BeginPrepareNextCard(context.Context) error
PrepareNextCard(context.Context) (string, error)
}
type App struct { type App struct {
disp *dispenser.Client disp doorCardDispenser
lockserver lockserver.LockServer lockserver lockserver.LockServer
paymentService *paymentsvc.Service paymentService *paymentsvc.Service
isPayment bool isPayment bool
@ -376,47 +385,69 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
return return
} }
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding. status, err := app.disp.PrepareCurrentCard(r.Context())
// With queued dispenser ops, this will not clash with polling.
status, err := app.disp.DispenserStart(r.Context())
app.SetCardWellStatus(status) app.SetCardWellStatus(status)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
if errors.Is(err, dispenser.ErrCardWellEmpty) {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
return
}
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()) errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
return return
} }
// Always attempt to finalize after we have moved a card / started an issuance flow.
// This guarantees we eject and prepare the next card even on lock failures.
finalize := func() {
if app.disp == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
status, ferr := app.disp.DispenserFinal(ctx)
if ferr != nil {
logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0)
return
}
app.SetCardWellStatus(status)
}
// doorReq.RoomField = "104" // doorReq.RoomField = "104"
// build lock server command // build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut) app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence // lock server sequence
if err := app.lockserver.LockSequence(); err != nil { encodingErr := app.lockserver.LockSequence()
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0) if encodingErr != nil {
finalize() logging.Error(types.ServiceName, encodingErr.Error(), "Key encoding", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error()) }
// Once encoding has started, finish the physical dispenser sequence even if
// the HTTP client disconnects. Finalization remains bounded by one shared timeout.
finalizeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
status, deliveryErr := app.disp.DeliverCurrentCard(finalizeCtx)
app.SetCardWellStatus(status)
if deliveryErr != nil {
if encodingErr != nil {
recoveryErr := fmt.Errorf("key encoding failed: %v; dispenser recovery failed: %w", encodingErr, deliveryErr)
logging.Error(types.ServiceName, recoveryErr.Error(), "Dispenser recovery", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser recovery failed; another encoding attempt is not safe")
return
}
logging.Error(types.ServiceName, deliveryErr.Error(), "Card delivery", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Card delivery could not be confirmed")
return return
} }
// final dispenser steps if encodingErr != nil {
finalize() status, preparationErr := app.disp.PrepareNextCard(finalizeCtx)
app.SetCardWellStatus(status)
if preparationErr != nil {
recoveryErr := fmt.Errorf("key encoding failed: %v; dispenser recovery failed: %w", encodingErr, preparationErr)
logging.Error(types.ServiceName, recoveryErr.Error(), "Dispenser recovery", string(op), "", "", 0)
if errors.Is(preparationErr, dispenser.ErrCardWellEmpty) {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
return
}
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispenser recovery failed; another encoding attempt is not safe")
return
}
errorhandlers.WriteError(w, http.StatusBadGateway, encodingErr.Error())
return
}
if preparationErr := app.disp.BeginPrepareNextCard(finalizeCtx); preparationErr != nil {
logging.Error(types.ServiceName, preparationErr.Error(), "Next card preparation dispatch", string(op), "", "", 0)
}
theResponse.Code = http.StatusOK theResponse.Code = http.StatusOK
theResponse.Message = "Card issued successfully" theResponse.Message = "Card issued successfully"

View File

@ -298,16 +298,16 @@ func setPaymentCORS(w http.ResponseWriter) {
} }
func buildPaymentSuccessURL(result *paymentsvc.Result) string { func buildPaymentSuccessURL(result *paymentsvc.Result) string {
txnReference := result.ReferenceNumber // txnReference := result.ReferenceNumber
if txnReference == "" { // if txnReference == "" {
txnReference = result.TransactionID // txnReference = result.TransactionID
} // }
q := url.Values{} q := url.Values{}
q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber))) q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber)))
q.Set("CardType", hex.EncodeToString([]byte(result.CardType))) q.Set("CardType", hex.EncodeToString([]byte(result.CardType)))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate))) q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate)))
q.Set("TxnReference", txnReference) q.Set("TxnReference", result.RequestID)
q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash))) q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash)))
q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference))) q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference)))

View File

@ -0,0 +1,162 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type paymentProviderFunc func(
context.Context,
paymentsvc.SaleRequest,
paymentsvc.StatusHandler,
) (*paymentsvc.Result, error)
func (f paymentProviderFunc) Sale(
ctx context.Context,
request paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
return f(ctx, request, onStatus)
}
func TestSalePaymentStreamsTerminalUnavailableAsAuthoritativeUnsuccessfulResult(t *testing.T) {
provider := paymentProviderFunc(func(
_ context.Context,
request paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.TerminalUnavailable})
return &paymentsvc.Result{
RequestID: request.RequestID,
Operation: "SALE",
Status: "TERMINAL_UNAVAILABLE",
ErrorMessage: "Payment terminal is unavailable",
Amount: request.Amount,
Currency: request.Currency,
DeviceUsed: "terminal-1",
DeviceType: "Dojo Terminal",
}, nil
})
recorder := performSalePaymentRequest(t, provider)
if recorder.Code != http.StatusOK {
t.Fatalf("outer status = %d, want %d", recorder.Code, http.StatusOK)
}
messages := decodePaymentStream(t, recorder.Body)
if len(messages) != 3 {
t.Fatalf("stream messages = %#v, want two statuses and one result", messages)
}
if messages[0].Type != "status" || messages[0].Code != paymentstatus.Starting ||
messages[1].Type != "status" || messages[1].Code != paymentstatus.TerminalUnavailable {
t.Fatalf("status frames = %#v", messages[:2])
}
final := messages[2]
if final.Type != "result" || final.Response == nil {
t.Fatalf("final frame = %#v", final)
}
if final.Response.Status.Code != http.StatusOK {
t.Fatalf("nested status = %d, want %d", final.Response.Status.Code, http.StatusOK)
}
resultURL, err := url.Parse(final.Response.Data)
if err != nil {
t.Fatal(err)
}
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
}
if got := resultURL.Query().Get("Description"); got != "Payment terminal is unavailable" {
t.Fatalf("Description = %q", got)
}
body := recorder.Body.String()
for _, forbidden := range []string{
"Dojo returned HTTP 409",
"offline or currently in use",
"traceId",
"docs.dojo.tech",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("stream exposed %q: %s", forbidden, body)
}
}
}
func TestSalePaymentRetainsNestedBadGatewayForProviderErrorsAfterStreamingStarts(t *testing.T) {
provider := paymentProviderFunc(func(
_ context.Context,
_ paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
return nil, errors.New("provider failed")
})
recorder := performSalePaymentRequest(t, provider)
if recorder.Code != http.StatusOK {
t.Fatalf("outer status = %d, want streaming status %d", recorder.Code, http.StatusOK)
}
messages := decodePaymentStream(t, recorder.Body)
if len(messages) != 2 || messages[1].Type != "result" || messages[1].Response == nil {
t.Fatalf("stream messages = %#v", messages)
}
if messages[1].Response.Status.Code != http.StatusBadGateway {
t.Fatalf("nested status = %d, want %d", messages[1].Response.Status.Code, http.StatusBadGateway)
}
resultURL, err := url.Parse(messages[1].Response.Data)
if err != nil {
t.Fatal(err)
}
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
}
}
func performSalePaymentRequest(t *testing.T, provider paymentsvc.Provider) *httptest.ResponseRecorder {
t.Helper()
app := &App{
paymentService: paymentsvc.NewService(provider),
cfg: &config.ConfigRec{
Hotel: "HOTEL",
Kiosk: 7,
TimeoutSeconds: 5,
},
}
request := httptest.NewRequest(
http.MethodPost,
"/api/payment/sale",
strings.NewReader(`{"reference":"BOOKING-123","amount":10852,"currency":"GBP"}`),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
app.salePayment(recorder, request)
return recorder
}
func decodePaymentStream(t *testing.T, body io.Reader) []paymentStreamMessage {
t.Helper()
decoder := json.NewDecoder(body)
var messages []paymentStreamMessage
for {
var message paymentStreamMessage
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
return messages
}
t.Fatal(err)
}
messages = append(messages, message)
}
}

View File

@ -9,10 +9,10 @@ import (
"time" "time"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail" "gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging" "gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -59,7 +59,7 @@ func (app *App) testIssueDoorCard(w http.ResponseWriter, r *http.Request) {
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding. // Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
// With queued dispenser ops, this will not clash with polling. // With queued dispenser ops, this will not clash with polling.
status, err := app.disp.DispenserStart(r.Context()) status, err := app.disp.PrepareCurrentCard(r.Context())
app.SetCardWellStatus(status) app.SetCardWellStatus(status)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)

View File

@ -152,6 +152,7 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest, onStatus pay
case types.MesTypePaymentResult: case types.MesTypePaymentResult:
var result PaymentResultEnvelope var result PaymentResultEnvelope
// log.Println("PayBridge payment result raw:", string(raw))
if err := json.Unmarshal(raw, &result); err != nil { if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("decode payment_result: %w", err) return nil, fmt.Errorf("decode payment_result: %w", err)
} }

View File

@ -34,6 +34,7 @@ const (
VoidFailed = "PAYMENT_VOID_FAILED" VoidFailed = "PAYMENT_VOID_FAILED"
LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR" LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR"
Error = "PAYMENT_ERROR" Error = "PAYMENT_ERROR"
TerminalUnavailable = "PAYMENT_TERMINAL_UNAVAILABLE"
DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_" DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_"
DojoStatusPrefix = "PAYMENT_DOJO_STATUS_" DojoStatusPrefix = "PAYMENT_DOJO_STATUS_"

View File

@ -42,6 +42,7 @@ func TestFixedStatusValues(t *testing.T) {
{"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"}, {"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"},
{"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"}, {"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"},
{"Error", paymentstatus.Error, "PAYMENT_ERROR"}, {"Error", paymentstatus.Error, "PAYMENT_ERROR"},
{"TerminalUnavailable", paymentstatus.TerminalUnavailable, "PAYMENT_TERMINAL_UNAVAILABLE"},
} }
seen := make(map[string]string, len(statuses)) seen := make(map[string]string, len(statuses))

View File

@ -2,7 +2,13 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.3.3 - 21 July 2026 #### v1.3.5 - 23 July 2026
fixed dispenser delivery confirmation
#### v1.3.4 - 23 July 2026
added support for Dojo terminal-unavailable responses
#### v1.3.3 - 21 July 2026
added PDQ status streaming to the payment flow to allow the front end to display the status of the PDQ terminal added PDQ status streaming to the payment flow to allow the front end to display the status of the PDQ terminal
#### 1.3.2 - 20 July 2026 #### 1.3.2 - 20 July 2026