feat: add partner interface, admin dashboard, and unit tests
Partner flow: QR scanner component (html5-qrcode) with camera permission/not-found handling, 3-step spend flow (scan → amount entry with auto-filled max → success/error result). Admin dashboard: stats overview, grant points with debounced student search, paginated transactions table with type filters, paginated students table. Tests: comprehensive unit tests for points and auth packages — service (all paths including error branches, RS256 wrong-method), handler (all HTTP status codes via httptest), JWT round-trip, repository constructors. Auth coverage: 72.9%, points service coverage: 100%. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
50b3c4198a
commit
be2260d259
@@ -0,0 +1,218 @@
|
||||
package points_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cu-points/backend/internal/middleware"
|
||||
"github.com/cu-points/backend/internal/points"
|
||||
)
|
||||
|
||||
// injectUserID puts a user_id into the request context the same way middleware.Auth does.
|
||||
func injectUserID(r *http.Request, userID string) *http.Request {
|
||||
return r.WithContext(middleware.ContextWithUserID(r.Context(), userID))
|
||||
}
|
||||
|
||||
func newHandlerWithService(repo points.Repository, cache points.CacheClient) *points.Handler {
|
||||
svc := points.NewService(repo, cache, testSecret)
|
||||
return points.NewHandler(svc)
|
||||
}
|
||||
|
||||
// ─── GenerateQR ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGenerateQR_Success(t *testing.T) {
|
||||
h := newHandlerWithService(&mockRepo{}, &mockCache{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/qr", nil)
|
||||
req = injectUserID(req, "user-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.GenerateQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if envelope.Data.Token == "" {
|
||||
t.Error("expected non-empty token in response")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSpend_Success(t *testing.T) {
|
||||
svc := points.NewService(
|
||||
&mockRepo{balance: 500, newBalance: 400},
|
||||
&mockCache{},
|
||||
testSecret,
|
||||
)
|
||||
// Generate a valid token first.
|
||||
token, _ := svc.GenerateQRToken(context.Background(), "student-1")
|
||||
|
||||
h := points.NewHandler(svc)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"qr_token": token,
|
||||
"amount": 100,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_InvalidJSON(t *testing.T) {
|
||||
h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend",
|
||||
strings.NewReader("{bad json"))
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_MissingQRToken(t *testing.T) {
|
||||
h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{})
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"amount": 100})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_ZeroAmount(t *testing.T) {
|
||||
h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{})
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"qr_token": "sometoken", "amount": 0})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_InvalidToken_Returns401(t *testing.T) {
|
||||
h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{})
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"qr_token": "bad.token.here", "amount": 100})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_AlreadyUsedToken_Returns409(t *testing.T) {
|
||||
svc := points.NewService(
|
||||
&mockRepo{balance: 500},
|
||||
&mockCache{used: true},
|
||||
testSecret,
|
||||
)
|
||||
token, _ := svc.GenerateQRToken(context.Background(), "student-1")
|
||||
h := points.NewHandler(svc)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"qr_token": token, "amount": 100})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_InternalError_Returns500(t *testing.T) {
|
||||
// Trigger the default error path by making the cache return a generic error.
|
||||
svc := points.NewService(
|
||||
&mockRepo{balance: 500},
|
||||
&mockCache{isErr: errors.New("redis timeout")},
|
||||
testSecret,
|
||||
)
|
||||
token, _ := svc.GenerateQRToken(context.Background(), "student-1")
|
||||
// Rebuild the service with the broken cache for the actual spend call.
|
||||
svc2 := points.NewService(
|
||||
&mockRepo{balance: 500},
|
||||
&mockCache{isErr: errors.New("redis timeout")},
|
||||
testSecret,
|
||||
)
|
||||
h := points.NewHandler(svc2)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"qr_token": token, "amount": 100})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpend_InsufficientBalance_Returns422(t *testing.T) {
|
||||
svc := points.NewService(
|
||||
&mockRepo{balance: 10},
|
||||
&mockCache{},
|
||||
testSecret,
|
||||
)
|
||||
token, _ := svc.GenerateQRToken(context.Background(), "student-1")
|
||||
h := points.NewHandler(svc)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"qr_token": token, "amount": 100})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = injectUserID(req, "partner-1")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Spend(w, req)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user