hardlink/internal/handlers/doorcard_handlers_test.go

314 lines
9.7 KiB
Go

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