Compare commits
No commits in common. "development" and "1.3.3" have entirely different histories.
developmen
...
1.3.3
@ -34,7 +34,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
buildVersion = "v1.3.5"
|
buildVersion = "1.3.3"
|
||||||
serviceName = "hardlink"
|
serviceName = "hardlink"
|
||||||
pollingFrequency = 8 * time.Second
|
pollingFrequency = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package dispenser
|
package dispenser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -23,13 +22,9 @@ 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
|
||||||
|
|
||||||
@ -72,9 +67,10 @@ var (
|
|||||||
// Status helpers
|
// Status helpers
|
||||||
// --------------------
|
// --------------------
|
||||||
|
|
||||||
func statusDescription(statusBytes []byte) string {
|
func logStatus(statusBytes []byte) {
|
||||||
if len(statusBytes) < 4 {
|
if len(statusBytes) < 4 {
|
||||||
return fmt.Sprintf("<invalid len=%d>", len(statusBytes))
|
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
posStatus := []struct {
|
posStatus := []struct {
|
||||||
@ -98,58 +94,13 @@ func statusDescription(statusBytes []byte) string {
|
|||||||
result.WriteString(statusMsg + "; ")
|
result.WriteString(statusMsg + "; ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result.String()
|
log.Infof("Dispenser status: %s", 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 ""
|
||||||
|
|||||||
@ -30,25 +30,12 @@ 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
|
||||||
@ -70,28 +57,12 @@ func NewClient(port *serial.Port, queueSize int) *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:
|
||||||
@ -297,152 +268,113 @@ func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
|
|||||||
return stockStatus, nil
|
return stockStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) readSequenceStatus(ctx context.Context, operation string) ([]byte, string, error) {
|
func (c *Client) DispenserStart(ctx context.Context) (string, error) {
|
||||||
status, err := c.do(ctx, cmdStatus)
|
const funcName = "DispenserStart"
|
||||||
|
stockStatus := ""
|
||||||
|
|
||||||
|
status, err := c.CheckStatus(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
|
||||||
return nil, "", fmt.Errorf("[%s] read status: %w", operation, ctxErr)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("[%s] read status: %w", operation, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stockStatus := ""
|
defer func() {
|
||||||
if len(status) == 4 {
|
logStatus(status)
|
||||||
|
}()
|
||||||
|
stockStatus = stockTake(status)
|
||||||
|
c.setStock(status)
|
||||||
|
|
||||||
|
if isCardWellEmpty(status) {
|
||||||
|
return stockStatus, fmt.Errorf(stockStatus)
|
||||||
|
}
|
||||||
|
if isAtEncoderPosition(status) {
|
||||||
|
return stockStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ToEncoder(ctx); err != nil {
|
||||||
|
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, 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)
|
stockStatus = stockTake(status)
|
||||||
c.setStock(status)
|
c.setStock(status)
|
||||||
logStatus(status)
|
logStatus(status)
|
||||||
}
|
|
||||||
return status, stockStatus, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preparationStatus(operation string, status []byte) (bool, error) {
|
// error states first
|
||||||
if len(status) != 4 {
|
|
||||||
return false, fmt.Errorf("[%s] %w", operation, validateDispenserStatusData(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) {
|
if isCardWellEmpty(status) {
|
||||||
return false, fmt.Errorf("[%s] %w", operation, ErrCardWellEmpty)
|
return stockStatus, fmt.Errorf(stockStatus)
|
||||||
}
|
|
||||||
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(
|
if isAtEncoderPosition(status) {
|
||||||
ctx context.Context,
|
return stockStatus, nil
|
||||||
operation string,
|
}
|
||||||
retryCommand func(context.Context) error,
|
}
|
||||||
) (string, error) {
|
}
|
||||||
started := c.sequenceTiming.now()
|
|
||||||
halfway := started.Add(sequenceRetryAfter)
|
func (c *Client) DispenserFinal(ctx context.Context) (string, error) {
|
||||||
deadline := started.Add(sequenceTimeout)
|
const funcName = "DispenserFinal"
|
||||||
retried := false
|
|
||||||
stockStatus := ""
|
stockStatus := ""
|
||||||
|
var status []byte
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ToEncoder(ctx); err != nil {
|
|
||||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", operation, err)
|
|
||||||
}
|
|
||||||
return c.pollForEncoderPosition(ctx, operation, c.ToEncoder)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrepareCurrentCard authoritatively places the card to be encoded at the encoder.
|
|
||||||
func (c *Client) PrepareCurrentCard(ctx context.Context) (string, error) {
|
|
||||||
return c.prepareCardAtEncoder(ctx, "PrepareCurrentCard")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 "", fmt.Errorf("[%s] out of mouth: %w", operation, err)
|
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err)
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeginPrepareNextCard starts moving the next card to the encoder without waiting for readiness.
|
time.Sleep(delay)
|
||||||
func (c *Client) BeginPrepareNextCard(ctx context.Context) error {
|
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 {
|
if err := c.ToEncoder(ctx); err != nil {
|
||||||
return fmt.Errorf("[BeginPrepareNextCard] to encoder: %w", err)
|
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareNextCard places a new card at the encoder for a later issuance attempt.
|
defer func() {
|
||||||
func (c *Client) PrepareNextCard(ctx context.Context) (string, error) {
|
logStatus(status)
|
||||||
return c.prepareCardAtEncoder(ctx, "PrepareNextCard")
|
}()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,404 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -36,20 +35,6 @@ 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
|
||||||
|
|
||||||
@ -118,21 +103,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -599,13 +569,10 @@ 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 &httpResponseError{
|
return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Body: strings.TrimSpace(string(responseBody)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if target == nil || len(responseBody) == 0 {
|
if target == nil || len(responseBody) == 0 {
|
||||||
|
|||||||
@ -1,199 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,8 +5,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@ -27,15 +25,8 @@ 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 doorCardDispenser
|
disp *dispenser.Client
|
||||||
lockserver lockserver.LockServer
|
lockserver lockserver.LockServer
|
||||||
paymentService *paymentsvc.Service
|
paymentService *paymentsvc.Service
|
||||||
isPayment bool
|
isPayment bool
|
||||||
@ -385,69 +376,47 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := app.disp.PrepareCurrentCard(r.Context())
|
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
|
||||||
|
// 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
|
||||||
encodingErr := app.lockserver.LockSequence()
|
if err := app.lockserver.LockSequence(); err != nil {
|
||||||
if encodingErr != nil {
|
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||||
logging.Error(types.ServiceName, encodingErr.Error(), "Key encoding", string(op), "", "", 0)
|
finalize()
|
||||||
}
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.Error(types.ServiceName, deliveryErr.Error(), "Card delivery", string(op), "", "", 0)
|
// final dispenser steps
|
||||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Card delivery could not be confirmed")
|
finalize()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if encodingErr != nil {
|
|
||||||
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"
|
||||||
|
|||||||
@ -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", result.RequestID)
|
q.Set("TxnReference", txnReference)
|
||||||
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)))
|
||||||
|
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.PrepareCurrentCard(r.Context())
|
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)
|
||||||
|
|||||||
@ -152,7 +152,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,6 @@ 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_"
|
||||||
|
|||||||
@ -42,7 +42,6 @@ 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))
|
||||||
|
|||||||
@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
builtVersion is a const in main.go
|
builtVersion is a const in main.go
|
||||||
|
|
||||||
#### v1.3.5 - 23 July 2026
|
#### 1.3.3 - 21 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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user