diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-16 21:51:17 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-16 21:51:17 +0900 |
| commit | 08c121c21a7e429e43e2d51fa4a3d8bd945c5d01 (patch) | |
| tree | 6912af4982952945d5c3402fb748ac09e63993cd /backend | |
| parent | 946ba064bcf625e32faf1f202e243fa6b2aa8e27 (diff) | |
| download | phperkaigi-2026-albatross-08c121c21a7e429e43e2d51fa4a3d8bd945c5d01.tar.gz phperkaigi-2026-albatross-08c121c21a7e429e43e2d51fa4a3d8bd945c5d01.tar.zst phperkaigi-2026-albatross-08c121c21a7e429e43e2d51fa4a3d8bd945c5d01.zip | |
test(backend): add unit tests for admin handlers and taskqueue
Add comprehensive tests for previously untested packages:
- admin: middleware auth checks, CRUD handlers for users/games/problems/testcases
- taskqueue: task creation, payload serialization, code hash calculation
- api: expose SetUserInContext helper for cross-package test support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/admin/handler_test.go | 1064 | ||||
| -rw-r--r-- | backend/api/auth_middleware.go | 5 | ||||
| -rw-r--r-- | backend/taskqueue/processor_test.go | 41 | ||||
| -rw-r--r-- | backend/taskqueue/tasks_test.go | 63 |
4 files changed, 1173 insertions, 0 deletions
diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go new file mode 100644 index 0000000..20c45ef --- /dev/null +++ b/backend/admin/handler_test.go @@ -0,0 +1,1064 @@ +package admin + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + + "albatross-2026-backend/api" + "albatross-2026-backend/config" + "albatross-2026-backend/db" +) + +// mockQuerier implements db.Querier for admin handler testing. +type mockQuerier struct { + db.Querier + getUserByIDFunc func(ctx context.Context, userID int32) (db.User, error) + listUsersFunc func(ctx context.Context) ([]db.User, error) + updateUserFunc func(ctx context.Context, arg db.UpdateUserParams) error + listAllGamesFunc func(ctx context.Context) ([]db.Game, error) + getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + listProblemsFunc func(ctx context.Context) ([]db.Problem, error) + getProblemByIDFunc func(ctx context.Context, problemID int32) (db.Problem, error) + createGameFunc func(ctx context.Context, arg db.CreateGameParams) (int32, error) + createProblemFunc func(ctx context.Context, arg db.CreateProblemParams) (int32, error) + updateProblemFunc func(ctx context.Context, arg db.UpdateProblemParams) error + listTestcasesByProblemIDFunc func(ctx context.Context, problemID int32) ([]db.Testcase, error) + getTestcaseByIDFunc func(ctx context.Context, testcaseID int32) (db.Testcase, error) + createTestcaseFunc func(ctx context.Context, arg db.CreateTestcaseParams) (int32, error) + updateTestcaseFunc func(ctx context.Context, arg db.UpdateTestcaseParams) error + deleteTestcaseFunc func(ctx context.Context, testcaseID int32) error + listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) + listSubmissionIDsFunc func(ctx context.Context) ([]int32, error) + getSubmissionsByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Submission, error) + getSubmissionByIDFunc func(ctx context.Context, submissionID int32) (db.Submission, error) + getTestcaseResultsBySubmIDFunc func(ctx context.Context, submissionID int32) ([]db.TestcaseResult, error) + listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error) + updateGameStartedAtFunc func(ctx context.Context, arg db.UpdateGameStartedAtParams) error +} + +func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) { + if m.getUserByIDFunc != nil { + return m.getUserByIDFunc(ctx, userID) + } + return db.User{}, pgx.ErrNoRows +} + +func (m *mockQuerier) ListUsers(ctx context.Context) ([]db.User, error) { + if m.listUsersFunc != nil { + return m.listUsersFunc(ctx) + } + return nil, nil +} + +func (m *mockQuerier) UpdateUser(ctx context.Context, arg db.UpdateUserParams) error { + if m.updateUserFunc != nil { + return m.updateUserFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) ListAllGames(ctx context.Context) ([]db.Game, error) { + if m.listAllGamesFunc != nil { + return m.listAllGamesFunc(ctx) + } + return nil, nil +} + +func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { + if m.getGameByIDFunc != nil { + return m.getGameByIDFunc(ctx, gameID) + } + return db.GetGameByIDRow{}, pgx.ErrNoRows +} + +func (m *mockQuerier) ListProblems(ctx context.Context) ([]db.Problem, error) { + if m.listProblemsFunc != nil { + return m.listProblemsFunc(ctx) + } + return nil, nil +} + +func (m *mockQuerier) GetProblemByID(ctx context.Context, problemID int32) (db.Problem, error) { + if m.getProblemByIDFunc != nil { + return m.getProblemByIDFunc(ctx, problemID) + } + return db.Problem{}, pgx.ErrNoRows +} + +func (m *mockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (int32, error) { + if m.createGameFunc != nil { + return m.createGameFunc(ctx, arg) + } + return 1, nil +} + +func (m *mockQuerier) CreateProblem(ctx context.Context, arg db.CreateProblemParams) (int32, error) { + if m.createProblemFunc != nil { + return m.createProblemFunc(ctx, arg) + } + return 1, nil +} + +func (m *mockQuerier) UpdateProblem(ctx context.Context, arg db.UpdateProblemParams) error { + if m.updateProblemFunc != nil { + return m.updateProblemFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) ListTestcasesByProblemID(ctx context.Context, problemID int32) ([]db.Testcase, error) { + if m.listTestcasesByProblemIDFunc != nil { + return m.listTestcasesByProblemIDFunc(ctx, problemID) + } + return nil, nil +} + +func (m *mockQuerier) GetTestcaseByID(ctx context.Context, testcaseID int32) (db.Testcase, error) { + if m.getTestcaseByIDFunc != nil { + return m.getTestcaseByIDFunc(ctx, testcaseID) + } + return db.Testcase{}, pgx.ErrNoRows +} + +func (m *mockQuerier) CreateTestcase(ctx context.Context, arg db.CreateTestcaseParams) (int32, error) { + if m.createTestcaseFunc != nil { + return m.createTestcaseFunc(ctx, arg) + } + return 1, nil +} + +func (m *mockQuerier) UpdateTestcase(ctx context.Context, arg db.UpdateTestcaseParams) error { + if m.updateTestcaseFunc != nil { + return m.updateTestcaseFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) DeleteTestcase(ctx context.Context, testcaseID int32) error { + if m.deleteTestcaseFunc != nil { + return m.deleteTestcaseFunc(ctx, testcaseID) + } + return nil +} + +func (m *mockQuerier) ListMainPlayers(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) { + if m.listMainPlayersFunc != nil { + return m.listMainPlayersFunc(ctx, gameIDs) + } + return nil, nil +} + +func (m *mockQuerier) ListSubmissionIDs(ctx context.Context) ([]int32, error) { + if m.listSubmissionIDsFunc != nil { + return m.listSubmissionIDsFunc(ctx) + } + return nil, nil +} + +func (m *mockQuerier) GetSubmissionsByGameID(ctx context.Context, gameID int32) ([]db.Submission, error) { + if m.getSubmissionsByGameIDFunc != nil { + return m.getSubmissionsByGameIDFunc(ctx, gameID) + } + return nil, nil +} + +func (m *mockQuerier) GetSubmissionByID(ctx context.Context, submissionID int32) (db.Submission, error) { + if m.getSubmissionByIDFunc != nil { + return m.getSubmissionByIDFunc(ctx, submissionID) + } + return db.Submission{}, pgx.ErrNoRows +} + +func (m *mockQuerier) GetTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) ([]db.TestcaseResult, error) { + if m.getTestcaseResultsBySubmIDFunc != nil { + return m.getTestcaseResultsBySubmIDFunc(ctx, submissionID) + } + return nil, nil +} + +func (m *mockQuerier) ListTestcasesByGameID(ctx context.Context, gameID int32) ([]db.Testcase, error) { + if m.listTestcasesByGameIDFunc != nil { + return m.listTestcasesByGameIDFunc(ctx, gameID) + } + return nil, nil +} + +func (m *mockQuerier) UpdateGameStartedAt(ctx context.Context, arg db.UpdateGameStartedAtParams) error { + if m.updateGameStartedAtFunc != nil { + return m.updateGameStartedAtFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) ListTestcasesByProblemIDForUpdate(ctx context.Context, problemID int32) ([]db.Testcase, error) { + return m.ListTestcasesByProblemID(ctx, problemID) +} + +// mockTxManager implements db.TxManager for testing. +type mockTxManager struct { + runInTxFunc func(ctx context.Context, fn func(q db.Querier) error) error +} + +func (m *mockTxManager) RunInTx(ctx context.Context, fn func(q db.Querier) error) error { + if m.runInTxFunc != nil { + return m.runInTxFunc(ctx, fn) + } + return fn(&mockQuerier{}) +} + +// mockRenderer implements echo.Renderer for testing. +type mockRenderer struct { + lastTemplateName string + lastData any +} + +func (r *mockRenderer) Render(_ io.Writer, name string, data interface{}, _ echo.Context) error { + r.lastTemplateName = name + r.lastData = data + return nil +} + +func newTestHandler(q *mockQuerier) *Handler { + return &Handler{ + q: q, + txm: &mockTxManager{}, + conf: &config.Config{BasePath: "/test/"}, + } +} + +func newEchoContext(method, path string, params map[string]string) (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + e.Renderer = &mockRenderer{} + req := httptest.NewRequest(method, path, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + if params != nil { + names := make([]string, 0, len(params)) + values := make([]string, 0, len(params)) + for k, v := range params { + names = append(names, k) + values = append(values, v) + } + c.SetParamNames(names...) + c.SetParamValues(values...) + } + return c, rec +} + +func newEchoContextWithForm(path string, params map[string]string, form url.Values) (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + e.Renderer = &mockRenderer{} + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + if params != nil { + names := make([]string, 0, len(params)) + values := make([]string, 0, len(params)) + for k, v := range params { + names = append(names, k) + values = append(values, v) + } + c.SetParamNames(names...) + c.SetParamValues(values...) + } + return c, rec +} + +// --- Admin middleware tests --- + +func setUserInContext(c echo.Context, user *db.User) { + ctx := api.SetUserInContext(c.Request().Context(), user) + c.SetRequest(c.Request().WithContext(ctx)) +} + +func TestAdminMiddleware_NoUser(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + middleware := h.newAdminMiddleware() + + c, rec := newEchoContext(http.MethodGet, "/admin/dashboard", nil) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }) + + err := handler(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } +} + +func TestAdminMiddleware_NonAdminUser(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + middleware := h.newAdminMiddleware() + + c, _ := newEchoContext(http.MethodGet, "/admin/dashboard", nil) + setUserInContext(c, &db.User{UserID: 1, IsAdmin: false}) + + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }) + + err := handler(c) + if err == nil { + t.Fatal("expected error for non-admin user") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusForbidden) + } +} + +func TestAdminMiddleware_AdminUser(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + middleware := h.newAdminMiddleware() + + c, rec := newEchoContext(http.MethodGet, "/admin/dashboard", nil) + setUserInContext(c, &db.User{UserID: 1, IsAdmin: true}) + + called := false + handler := middleware(func(c echo.Context) error { + called = true + return c.String(http.StatusOK, "ok") + }) + + err := handler(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Error("next handler was not called") + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +// --- Handler tests --- + +func TestGetUsers_Success(t *testing.T) { + q := &mockQuerier{ + listUsersFunc: func(_ context.Context) ([]db.User, error) { + return []db.User{ + {UserID: 1, Username: "alice", DisplayName: "Alice", IsAdmin: false}, + {UserID: 2, Username: "bob", DisplayName: "Bob", IsAdmin: true}, + }, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/users", nil) + err := h.getUsers(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetUsers_DBError(t *testing.T) { + q := &mockQuerier{ + listUsersFunc: func(_ context.Context) ([]db.User, error) { + return nil, errors.New("db error") + }, + } + h := newTestHandler(q) + + c, _ := newEchoContext(http.MethodGet, "/admin/users", nil) + err := h.getUsers(c) + if err == nil { + t.Fatal("expected error") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusInternalServerError) + } +} + +func TestGetUserEdit_Success(t *testing.T) { + q := &mockQuerier{ + getUserByIDFunc: func(_ context.Context, userID int32) (db.User, error) { + return db.User{UserID: userID, Username: "alice", DisplayName: "Alice"}, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/users/1", map[string]string{"userID": "1"}) + err := h.getUserEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetUserEdit_InvalidID(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + c, _ := newEchoContext(http.MethodGet, "/admin/users/abc", map[string]string{"userID": "abc"}) + err := h.getUserEdit(c) + if err == nil { + t.Fatal("expected error for invalid userID") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusBadRequest) + } +} + +func TestGetUserEdit_NotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + c, _ := newEchoContext(http.MethodGet, "/admin/users/999", map[string]string{"userID": "999"}) + err := h.getUserEdit(c) + if err == nil { + t.Fatal("expected error for non-existent user") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} + +func TestPostUserEdit_Success(t *testing.T) { + var updatedParams db.UpdateUserParams + q := &mockQuerier{ + updateUserFunc: func(_ context.Context, arg db.UpdateUserParams) error { + updatedParams = arg + return nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "display_name": {"Alice Updated"}, + "icon_path": {"files/img/alice/icon.png"}, + "is_admin": {"on"}, + "label": {"sponsor"}, + } + c, rec := newEchoContextWithForm("/admin/users/1", map[string]string{"userID": "1"}, form) + + err := h.postUserEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if updatedParams.UserID != 1 { + t.Errorf("UserID = %d, want 1", updatedParams.UserID) + } + if updatedParams.DisplayName != "Alice Updated" { + t.Errorf("DisplayName = %q, want %q", updatedParams.DisplayName, "Alice Updated") + } + if !updatedParams.IsAdmin { + t.Error("IsAdmin should be true") + } +} + +func TestPostUserEdit_EmptyOptionalFields(t *testing.T) { + var updatedParams db.UpdateUserParams + q := &mockQuerier{ + updateUserFunc: func(_ context.Context, arg db.UpdateUserParams) error { + updatedParams = arg + return nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "display_name": {"Bob"}, + } + c, _ := newEchoContextWithForm("/admin/users/2", map[string]string{"userID": "2"}, form) + + err := h.postUserEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updatedParams.IconPath != nil { + t.Errorf("IconPath should be nil for empty value, got %v", updatedParams.IconPath) + } + if updatedParams.IsAdmin { + t.Error("IsAdmin should be false when not in form") + } + if updatedParams.Label != nil { + t.Errorf("Label should be nil for empty value, got %v", updatedParams.Label) + } +} + +func TestGetGames_Success(t *testing.T) { + q := &mockQuerier{ + listAllGamesFunc: func(_ context.Context) ([]db.Game, error) { + return []db.Game{ + {GameID: 1, GameType: "golf", DisplayName: "Game 1", DurationSeconds: 300, ProblemID: 1}, + }, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/games", nil) + err := h.getGames(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestPostGameNew_Success(t *testing.T) { + var createdParams db.CreateGameParams + q := &mockQuerier{ + createGameFunc: func(_ context.Context, arg db.CreateGameParams) (int32, error) { + createdParams = arg + return 1, nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "game_type": {"golf"}, + "is_public": {"on"}, + "display_name": {"Test Game"}, + "duration_seconds": {"300"}, + "problem_id": {"1"}, + } + c, rec := newEchoContextWithForm("/admin/games/new", nil, form) + + err := h.postGameNew(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if createdParams.GameType != "golf" { + t.Errorf("GameType = %q, want %q", createdParams.GameType, "golf") + } + if !createdParams.IsPublic { + t.Error("IsPublic should be true") + } + if createdParams.DurationSeconds != 300 { + t.Errorf("DurationSeconds = %d, want 300", createdParams.DurationSeconds) + } + if createdParams.ProblemID != 1 { + t.Errorf("ProblemID = %d, want 1", createdParams.ProblemID) + } +} + +func TestPostGameNew_InvalidDuration(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + form := url.Values{ + "game_type": {"golf"}, + "display_name": {"Test Game"}, + "duration_seconds": {"invalid"}, + "problem_id": {"1"}, + } + c, _ := newEchoContextWithForm("/admin/games/new", nil, form) + + err := h.postGameNew(c) + if err == nil { + t.Fatal("expected error for invalid duration") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusBadRequest) + } +} + +func TestPostGameNew_InvalidProblemID(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + form := url.Values{ + "game_type": {"golf"}, + "display_name": {"Test Game"}, + "duration_seconds": {"300"}, + "problem_id": {"invalid"}, + } + c, _ := newEchoContextWithForm("/admin/games/new", nil, form) + + err := h.postGameNew(c) + if err == nil { + t.Fatal("expected error for invalid problem_id") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusBadRequest) + } +} + +func TestGetProblems_Success(t *testing.T) { + q := &mockQuerier{ + listProblemsFunc: func(_ context.Context) ([]db.Problem, error) { + return []db.Problem{ + {ProblemID: 1, Title: "Hello World", Description: "Print hello", Language: "php"}, + }, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/problems", nil) + err := h.getProblems(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestPostProblemNew_Success(t *testing.T) { + var createdParams db.CreateProblemParams + q := &mockQuerier{ + createProblemFunc: func(_ context.Context, arg db.CreateProblemParams) (int32, error) { + createdParams = arg + return 1, nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "title": {"FizzBuzz"}, + "description": {"Write FizzBuzz"}, + "language": {"php"}, + "sample_code": {"<?php echo 1;"}, + } + c, rec := newEchoContextWithForm("/admin/problems/new", nil, form) + + err := h.postProblemNew(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if createdParams.Title != "FizzBuzz" { + t.Errorf("Title = %q, want %q", createdParams.Title, "FizzBuzz") + } + if createdParams.Language != "php" { + t.Errorf("Language = %q, want %q", createdParams.Language, "php") + } +} + +func TestGetProblemEdit_Success(t *testing.T) { + q := &mockQuerier{ + getProblemByIDFunc: func(_ context.Context, problemID int32) (db.Problem, error) { + return db.Problem{ProblemID: problemID, Title: "Test", Language: "php"}, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/problems/1", map[string]string{"problemID": "1"}) + err := h.getProblemEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetProblemEdit_NotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + c, _ := newEchoContext(http.MethodGet, "/admin/problems/999", map[string]string{"problemID": "999"}) + err := h.getProblemEdit(c) + if err == nil { + t.Fatal("expected error for non-existent problem") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} + +func TestPostProblemEdit_Success(t *testing.T) { + var updatedParams db.UpdateProblemParams + q := &mockQuerier{ + updateProblemFunc: func(_ context.Context, arg db.UpdateProblemParams) error { + updatedParams = arg + return nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "title": {"Updated Title"}, + "description": {"Updated Desc"}, + "language": {"php"}, + "sample_code": {"<?php echo 2;"}, + } + c, rec := newEchoContextWithForm("/admin/problems/1", map[string]string{"problemID": "1"}, form) + + err := h.postProblemEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if updatedParams.ProblemID != 1 { + t.Errorf("ProblemID = %d, want 1", updatedParams.ProblemID) + } + if updatedParams.Title != "Updated Title" { + t.Errorf("Title = %q, want %q", updatedParams.Title, "Updated Title") + } +} + +func TestGetTestcases_Success(t *testing.T) { + q := &mockQuerier{ + getProblemByIDFunc: func(_ context.Context, problemID int32) (db.Problem, error) { + return db.Problem{ProblemID: problemID, Title: "Test Problem"}, nil + }, + listTestcasesByProblemIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) { + return []db.Testcase{ + {TestcaseID: 1, ProblemID: 1, Stdin: "in", Stdout: "out"}, + }, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/problems/1/testcases", map[string]string{"problemID": "1"}) + err := h.getTestcases(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestPostTestcaseNew_Success(t *testing.T) { + var createdParams db.CreateTestcaseParams + q := &mockQuerier{ + createTestcaseFunc: func(_ context.Context, arg db.CreateTestcaseParams) (int32, error) { + createdParams = arg + return 1, nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "stdin": {"hello"}, + "stdout": {"world"}, + } + c, rec := newEchoContextWithForm("/admin/problems/1/testcases/new", map[string]string{"problemID": "1"}, form) + + err := h.postTestcaseNew(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if createdParams.ProblemID != 1 { + t.Errorf("ProblemID = %d, want 1", createdParams.ProblemID) + } + if createdParams.Stdin != "hello" { + t.Errorf("Stdin = %q, want %q", createdParams.Stdin, "hello") + } +} + +func TestPostTestcaseEdit_Success(t *testing.T) { + q := &mockQuerier{ + getTestcaseByIDFunc: func(_ context.Context, testcaseID int32) (db.Testcase, error) { + return db.Testcase{TestcaseID: testcaseID, ProblemID: 1}, nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "stdin": {"updated_in"}, + "stdout": {"updated_out"}, + } + c, rec := newEchoContextWithForm("/admin/problems/1/testcases/1", map[string]string{ + "problemID": "1", + "testcaseID": "1", + }, form) + + err := h.postTestcaseEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } +} + +func TestPostTestcaseEdit_WrongProblem(t *testing.T) { + q := &mockQuerier{ + getTestcaseByIDFunc: func(_ context.Context, testcaseID int32) (db.Testcase, error) { + return db.Testcase{TestcaseID: testcaseID, ProblemID: 2}, nil + }, + } + h := newTestHandler(q) + + form := url.Values{ + "stdin": {"in"}, + "stdout": {"out"}, + } + c, _ := newEchoContextWithForm("/admin/problems/1/testcases/1", map[string]string{ + "problemID": "1", + "testcaseID": "1", + }, form) + + err := h.postTestcaseEdit(c) + if err == nil { + t.Fatal("expected error when testcase belongs to different problem") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} + +func TestPostTestcaseDelete_Success(t *testing.T) { + deletedID := int32(0) + q := &mockQuerier{ + getTestcaseByIDFunc: func(_ context.Context, testcaseID int32) (db.Testcase, error) { + return db.Testcase{TestcaseID: testcaseID, ProblemID: 1}, nil + }, + deleteTestcaseFunc: func(_ context.Context, testcaseID int32) error { + deletedID = testcaseID + return nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContextWithForm("/admin/problems/1/testcases/5/delete", map[string]string{ + "problemID": "1", + "testcaseID": "5", + }, url.Values{}) + + err := h.postTestcaseDelete(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if deletedID != 5 { + t.Errorf("deleted testcase ID = %d, want 5", deletedID) + } +} + +func TestPostTestcaseDelete_WrongProblem(t *testing.T) { + q := &mockQuerier{ + getTestcaseByIDFunc: func(_ context.Context, testcaseID int32) (db.Testcase, error) { + return db.Testcase{TestcaseID: testcaseID, ProblemID: 99}, nil + }, + } + h := newTestHandler(q) + + c, _ := newEchoContextWithForm("/admin/problems/1/testcases/5/delete", map[string]string{ + "problemID": "1", + "testcaseID": "5", + }, url.Values{}) + + err := h.postTestcaseDelete(c) + if err == nil { + t.Fatal("expected error when testcase belongs to different problem") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} + +func TestPostGameStart_Success(t *testing.T) { + q := &mockQuerier{ + getGameByIDFunc: func(_ context.Context, gameID int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{GameID: gameID, ProblemID: 1}, nil + }, + listTestcasesByProblemIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) { + return []db.Testcase{{TestcaseID: 1, ProblemID: 1}}, nil + }, + updateGameStartedAtFunc: func(_ context.Context, _ db.UpdateGameStartedAtParams) error { + return nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContextWithForm("/admin/games/1/start", map[string]string{"gameID": "1"}, url.Values{}) + + err := h.postGameStart(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } +} + +func TestPostGameStart_GameNotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + c, _ := newEchoContextWithForm("/admin/games/999/start", map[string]string{"gameID": "999"}, url.Values{}) + + err := h.postGameStart(c) + if err == nil { + t.Fatal("expected error for non-existent game") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} + +func TestPostGameStart_NoTestcases(t *testing.T) { + q := &mockQuerier{ + getGameByIDFunc: func(_ context.Context, gameID int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{GameID: gameID, ProblemID: 1}, nil + }, + listTestcasesByProblemIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) { + return []db.Testcase{}, nil + }, + } + h := newTestHandler(q) + + c, _ := newEchoContextWithForm("/admin/games/1/start", map[string]string{"gameID": "1"}, url.Values{}) + + err := h.postGameStart(c) + if err == nil { + t.Fatal("expected error when no testcases") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusBadRequest) + } +} + +func TestGetSubmissions_Success(t *testing.T) { + q := &mockQuerier{ + getSubmissionsByGameIDFunc: func(_ context.Context, _ int32) ([]db.Submission, error) { + return []db.Submission{ + { + SubmissionID: 1, + GameID: 1, + UserID: 1, + Status: "pass", + CodeSize: 42, + CreatedAt: pgtype.Timestamp{Valid: true}, + }, + }, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/games/1/submissions", map[string]string{"gameID": "1"}) + err := h.getSubmissions(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetSubmissionDetail_Success(t *testing.T) { + q := &mockQuerier{ + getSubmissionByIDFunc: func(_ context.Context, submissionID int32) (db.Submission, error) { + return db.Submission{ + SubmissionID: submissionID, + GameID: 1, + UserID: 1, + Code: "<?php echo 1;", + CodeSize: 14, + Status: "pass", + CreatedAt: pgtype.Timestamp{Valid: true}, + }, nil + }, + getTestcaseResultsBySubmIDFunc: func(_ context.Context, _ int32) ([]db.TestcaseResult, error) { + return []db.TestcaseResult{ + { + TestcaseResultID: 1, + SubmissionID: 1, + TestcaseID: 1, + Status: "pass", + CreatedAt: pgtype.Timestamp{Valid: true}, + }, + }, nil + }, + } + h := newTestHandler(q) + + c, rec := newEchoContext(http.MethodGet, "/admin/games/1/submissions/1", map[string]string{ + "gameID": "1", + "submissionID": "1", + }) + err := h.getSubmissionDetail(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetSubmissionDetail_NotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + + c, _ := newEchoContext(http.MethodGet, "/admin/games/1/submissions/999", map[string]string{ + "gameID": "1", + "submissionID": "999", + }) + err := h.getSubmissionDetail(c) + if err == nil { + t.Fatal("expected error for non-existent submission") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} diff --git a/backend/api/auth_middleware.go b/backend/api/auth_middleware.go index 0b0dfc8..94ef4e4 100644 --- a/backend/api/auth_middleware.go +++ b/backend/api/auth_middleware.go @@ -42,3 +42,8 @@ func GetUserFromContext(ctx context.Context) (*db.User, bool) { user, ok := ctx.Value(userContextKey{}).(*db.User) return user, ok } + +// SetUserInContext sets a user in the context. Intended for testing. +func SetUserInContext(ctx context.Context, user *db.User) context.Context { + return context.WithValue(ctx, userContextKey{}, user) +} diff --git a/backend/taskqueue/processor_test.go b/backend/taskqueue/processor_test.go new file mode 100644 index 0000000..3646b4c --- /dev/null +++ b/backend/taskqueue/processor_test.go @@ -0,0 +1,41 @@ +package taskqueue + +import ( + "testing" +) + +func TestCalcCodeHash(t *testing.T) { + // Same code + same testcaseID should produce same hash + hash1 := calcCodeHash("echo hello", 1) + hash2 := calcCodeHash("echo hello", 1) + if hash1 != hash2 { + t.Errorf("same input produced different hashes: %q vs %q", hash1, hash2) + } + + // Different code should produce different hash + hash3 := calcCodeHash("echo world", 1) + if hash1 == hash3 { + t.Errorf("different code produced same hash: %q", hash1) + } + + // Different testcaseID should produce different hash + hash4 := calcCodeHash("echo hello", 2) + if hash1 == hash4 { + t.Errorf("different testcaseID produced same hash: %q", hash1) + } + + // Hash should be a valid hex md5 (32 characters) + if len(hash1) != 32 { + t.Errorf("hash length = %d, want 32", len(hash1)) + } +} + +func TestCalcCodeHash_EmptyCode(t *testing.T) { + hash := calcCodeHash("", 0) + if hash == "" { + t.Error("hash should not be empty for empty code") + } + if len(hash) != 32 { + t.Errorf("hash length = %d, want 32", len(hash)) + } +} diff --git a/backend/taskqueue/tasks_test.go b/backend/taskqueue/tasks_test.go new file mode 100644 index 0000000..5eaaf71 --- /dev/null +++ b/backend/taskqueue/tasks_test.go @@ -0,0 +1,63 @@ +package taskqueue + +import ( + "encoding/json" + "testing" +) + +func TestNewTaskRunTestcase(t *testing.T) { + task, err := newTaskRunTestcase(1, 2, 3, 4, "php", "<?php echo 1;", "input", "output") + if err != nil { + t.Fatalf("newTaskRunTestcase returned error: %v", err) + } + if task.Type() != string(TaskTypeRunTestcase) { + t.Errorf("task type = %q, want %q", task.Type(), TaskTypeRunTestcase) + } + + var payload TaskPayloadRunTestcase + if err := json.Unmarshal(task.Payload(), &payload); err != nil { + t.Fatalf("failed to unmarshal payload: %v", err) + } + if payload.GameID != 1 { + t.Errorf("GameID = %d, want 1", payload.GameID) + } + if payload.UserID != 2 { + t.Errorf("UserID = %d, want 2", payload.UserID) + } + if payload.SubmissionID != 3 { + t.Errorf("SubmissionID = %d, want 3", payload.SubmissionID) + } + if payload.TestcaseID != 4 { + t.Errorf("TestcaseID = %d, want 4", payload.TestcaseID) + } + if payload.Language != "php" { + t.Errorf("Language = %q, want %q", payload.Language, "php") + } + if payload.Code != "<?php echo 1;" { + t.Errorf("Code = %q, want %q", payload.Code, "<?php echo 1;") + } + if payload.Stdin != "input" { + t.Errorf("Stdin = %q, want %q", payload.Stdin, "input") + } + if payload.Stdout != "output" { + t.Errorf("Stdout = %q, want %q", payload.Stdout, "output") + } +} + +func TestTaskResultRunTestcase_Interface(t *testing.T) { + result := &TaskResultRunTestcase{ + TaskPayload: &TaskPayloadRunTestcase{GameID: 42}, + Status: "pass", + Stdout: "hello", + Stderr: "", + } + + var _ TaskResult = result + + if result.Type() != TaskTypeRunTestcase { + t.Errorf("Type() = %q, want %q", result.Type(), TaskTypeRunTestcase) + } + if result.GameID() != 42 { + t.Errorf("GameID() = %d, want 42", result.GameID()) + } +} |
