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 activity int registrations int releases int outsideActivity bool calls []string } func (d *fakeDoorCardDispenser) PrepareCurrentCard(context.Context) (string, error) { if d.activity == 0 { d.outsideActivity = true } 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 retryable", dispenser: fakeDoorCardDispenser{ prepareCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("card jammed")}, }, wantHTTP: http.StatusBadGateway, 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: "exhausted preparation is retryable without encoding or delivery", dispenser: fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: errors.Join(errors.New("preparation"), dispenser.ErrPreparationExhausted)}}, wantHTTP: http.StatusBadGateway, wantMessage: "preparation\ncard preparation exhausted", wantCalls: []string{"prepare current"}, }, { 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 still prestages and succeeds", dispenser: fakeDoorCardDispenser{ deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")}, }, wantHTTP: http.StatusOK, wantMessage: "Card issued successfully", wantCalls: []string{"prepare current", "deliver current", "begin prepare next"}, 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: "delivery clearance deferral does not undo successful issuance", dispenser: fakeDoorCardDispenser{ beginNextErr: errors.New("next-card preparation deferred: previous delivery is not clear"), }, wantHTTP: http.StatusOK, wantMessage: "Card issued successfully", wantCalls: []string{"prepare current", "deliver current", "begin prepare next"}, wantLockSequence: 1, }, { name: "encoding failure ends after delivery", lockErr: encodingErr, wantHTTP: http.StatusBadGateway, wantMessage: encodingErr.Error(), wantCalls: []string{"prepare current", "deliver current"}, wantLockSequence: 1, }, { name: "encoding failure remains retryable despite FC0 failure", dispenser: fakeDoorCardDispenser{ deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")}, }, lockErr: encodingErr, wantHTTP: http.StatusBadGateway, wantMessage: encodingErr.Error(), wantCalls: []string{"prepare current", "deliver current"}, wantLockSequence: 1, wantCardWell: "Card jammed", }, { name: "encoding failure never prepares next card", dispenser: fakeDoorCardDispenser{ prepareNext: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty}, }, lockErr: encodingErr, wantHTTP: http.StatusBadGateway, wantMessage: encodingErr.Error(), wantCalls: []string{"prepare current", "deliver current"}, wantLockSequence: 1, }, } 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) } } func TestIssueDoorCardLogsDeliveryFailureWithoutReplacingEncoderOutcome(t *testing.T) { for _, encodingErr := range []error{nil, errors.New("original encoder failure")} { var output bytes.Buffer logger := log.StandardLogger() previous := logger.Out logger.SetOutput(&output) d := &fakeDoorCardDispenser{deliverCurrent: dispenserCallResult{err: errors.New("FC0 dispatch failed")}} lock := &fakeDoorCardLockServer{sequenceErr: encodingErr} recorder, response, _ := performIssueDoorCardRequest(t, d, lock) logger.SetOutput(previous) wantHTTP := http.StatusOK if encodingErr != nil { wantHTTP = http.StatusBadGateway } if recorder.Code != wantHTTP { t.Errorf("issueDoorCard(%v) HTTP = %d, want %d", encodingErr, recorder.Code, wantHTTP) } if encodingErr != nil && response.Message != encodingErr.Error() { t.Errorf("issueDoorCard message = %q, want %q", response.Message, encodingErr.Error()) } if !strings.Contains(output.String(), "FC0 dispatch failed") || !strings.Contains(output.String(), "Card delivery") { t.Errorf("issueDoorCard delivery log = %q, want FC0 failure and Card delivery", output.String()) } } } func TestIssueDoorCardOnlyEmptyPreparationReturns503(t *testing.T) { for _, failure := range []error{ dispenser.ErrCardWellEmpty, errors.Join(errors.New("wrapped"), dispenser.ErrCardWellEmpty), dispenser.ErrPreparationExhausted, context.Canceled, context.DeadlineExceeded, errors.New("malformed AP"), errors.New("truncated AP"), errors.New("no response"), errors.New("serial read failed"), errors.New("serial write failed"), errors.New("FC7 dispatch failed"), errors.New("RS dispatch failed"), errors.New(dispenser.CardWellEmptyMessage), errors.New("other preparation error"), } { d := &fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: failure}} lock := &fakeDoorCardLockServer{} recorder, response, _ := performIssueDoorCardRequest(t, d, lock) want := http.StatusBadGateway if errors.Is(failure, dispenser.ErrCardWellEmpty) { want = http.StatusServiceUnavailable } if recorder.Code != want || response.Code != want { t.Errorf("preparation %v HTTP=%d body=%d, want %d", failure, recorder.Code, response.Code, want) } if lock.sequenceCalls != 0 || !reflect.DeepEqual(d.calls, []string{"prepare current"}) { t.Errorf("preparation %v encoder=%d calls=%v, want no physical continuation", failure, lock.sequenceCalls, d.calls) } } } func (d *fakeDoorCardDispenser) BeginForeground() func() { d.activity++ d.registrations++ return func() { d.activity--; d.releases++ } } func TestDoorCardForegroundLifecycle(t *testing.T) { for _, testEndpoint := range []bool{false, true} { d := &fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: errors.New("stop before encoding")}} lock := &fakeDoorCardLockServer{} _, _, app := performIssueDoorCardRequest(t, d, lock) if d.registrations != 1 || d.releases != 1 || d.activity != 0 || d.outsideActivity { t.Fatalf("issue registration=%d release=%d active=%d outside=%t", d.registrations, d.releases, d.activity, d.outsideActivity) } if testEndpoint { req := httptest.NewRequest(http.MethodPost, "/testissuedoorcard", strings.NewReader("{}")) req.Header.Set("Content-Type", "application/json") app.testIssueDoorCard(httptest.NewRecorder(), req) if d.registrations != 2 || d.releases != 2 || d.activity != 0 || d.outsideActivity { t.Errorf("test endpoint registration=%d release=%d active=%d outside=%t", d.registrations, d.releases, d.activity, d.outsideActivity) } } } }