314 lines
9.7 KiB
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)
|
|
}
|
|
}
|