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