aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-16 20:05:39 +0900
committernsfisis <nsfisis@gmail.com>2026-02-16 20:13:47 +0900
commit071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d (patch)
treed0174928c0d320bb76e0cdb899beee0476643d55 /backend
parent5ed369a6c70707543fd5ec9a13c79851fdfc5d6c (diff)
downloadphperkaigi-2026-albatross-071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d.tar.gz
phperkaigi-2026-albatross-071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d.tar.zst
phperkaigi-2026-albatross-071e7cc78d3f13fa782dbc6ca5fcec3a37263a4d.zip
test(backend): add unit tests for auth, config, ratelimit, game, and api
Cover previously untested logic: session ID generation/hashing, password authentication, IP rate limiting, game state helpers, handler endpoints, task enqueue/result processing, and config loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend')
-rw-r--r--backend/api/handler_test.go245
-rw-r--r--backend/auth/auth_test.go125
-rw-r--r--backend/auth/session_test.go47
-rw-r--r--backend/config/config_test.go139
-rw-r--r--backend/game/hub_test.go249
-rw-r--r--backend/justfile2
-rw-r--r--backend/ratelimit/ratelimit_test.go123
7 files changed, 928 insertions, 2 deletions
diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go
index 47ad92c..a54f995 100644
--- a/backend/api/handler_test.go
+++ b/backend/api/handler_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
+ "time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -15,7 +16,8 @@ import (
// mockQuerier implements db.Querier for testing.
type mockQuerier struct {
db.Querier
- getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error)
+ getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error)
+ listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error)
}
func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) {
@@ -25,6 +27,13 @@ func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGame
return db.GetGameByIDRow{}, pgx.ErrNoRows
}
+func (m *mockQuerier) ListMainPlayers(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) {
+ if m.listMainPlayersFunc != nil {
+ return m.listMainPlayersFunc(ctx, gameIDs)
+ }
+ return nil, nil
+}
+
// mockTxManager implements db.TxManager for testing.
type mockTxManager struct{}
@@ -110,6 +119,240 @@ func TestPostGamePlaySubmit_GameNotRunning(t *testing.T) {
}
}
+func TestIsGameRunning(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ game db.GetGameByIDRow
+ want bool
+ }{
+ {
+ name: "not started",
+ game: db.GetGameByIDRow{
+ StartedAt: pgtype.Timestamp{Valid: false},
+ DurationSeconds: 300,
+ },
+ want: false,
+ },
+ {
+ name: "running",
+ game: db.GetGameByIDRow{
+ StartedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true},
+ DurationSeconds: 300,
+ },
+ want: true,
+ },
+ {
+ name: "finished",
+ game: db.GetGameByIDRow{
+ StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true},
+ DurationSeconds: 300,
+ },
+ want: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isGameRunning(tt.game)
+ if got != tt.want {
+ t.Errorf("isGameRunning() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsGameFinished(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ game db.GetGameByIDRow
+ want bool
+ }{
+ {
+ name: "not started",
+ game: db.GetGameByIDRow{
+ StartedAt: pgtype.Timestamp{Valid: false},
+ DurationSeconds: 300,
+ },
+ want: false,
+ },
+ {
+ name: "still running",
+ game: db.GetGameByIDRow{
+ StartedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true},
+ DurationSeconds: 300,
+ },
+ want: false,
+ },
+ {
+ name: "finished",
+ game: db.GetGameByIDRow{
+ StartedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true},
+ DurationSeconds: 300,
+ },
+ want: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isGameFinished(tt.game)
+ if got != tt.want {
+ t.Errorf("isGameFinished() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestToNullable(t *testing.T) {
+ t.Run("nil value", func(t *testing.T) {
+ result := toNullable[string](nil)
+ if !result.IsNull() {
+ t.Error("expected null for nil input")
+ }
+ })
+ t.Run("non-nil value", func(t *testing.T) {
+ s := "hello"
+ result := toNullable(&s)
+ if result.IsNull() {
+ t.Error("expected non-null for non-nil input")
+ }
+ v, err := result.Get()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if v != "hello" {
+ t.Errorf("expected 'hello', got %q", v)
+ }
+ })
+}
+
+func TestGetMe(t *testing.T) {
+ h := Handler{
+ q: &mockQuerier{},
+ txm: &mockTxManager{},
+ hub: &mockGameHub{},
+ auth: &mockAuthenticator{},
+ conf: &config.Config{},
+ }
+ user := &db.User{
+ UserID: 1,
+ Username: "testuser",
+ DisplayName: "Test User",
+ IsAdmin: false,
+ }
+ resp, err := h.GetMe(context.Background(), GetMeRequestObject{}, user)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ okResp, ok := resp.(GetMe200JSONResponse)
+ if !ok {
+ t.Fatalf("expected 200 response, got %T", resp)
+ }
+ if okResp.User.UserID != 1 {
+ t.Errorf("expected user ID 1, got %d", okResp.User.UserID)
+ }
+ if okResp.User.Username != "testuser" {
+ t.Errorf("expected username 'testuser', got %q", okResp.User.Username)
+ }
+ if okResp.User.IsAdmin {
+ t.Error("expected non-admin user")
+ }
+}
+
+func TestGetGame_NotFound(t *testing.T) {
+ h := Handler{
+ q: &mockQuerier{},
+ txm: &mockTxManager{},
+ hub: &mockGameHub{},
+ auth: &mockAuthenticator{},
+ conf: &config.Config{},
+ }
+ user := &db.User{UserID: 1}
+ resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 999}, user)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, ok := resp.(GetGame404JSONResponse); !ok {
+ t.Errorf("expected 404 response, got %T", resp)
+ }
+}
+
+func TestGetGame_NonPublicAsNonAdmin(t *testing.T) {
+ h := Handler{
+ q: &mockQuerier{
+ getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{
+ GameID: 1,
+ IsPublic: false,
+ Language: "php",
+ }, nil
+ },
+ },
+ txm: &mockTxManager{},
+ hub: &mockGameHub{},
+ auth: &mockAuthenticator{},
+ conf: &config.Config{},
+ }
+ user := &db.User{UserID: 1, IsAdmin: false}
+ resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 1}, user)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, ok := resp.(GetGame404JSONResponse); !ok {
+ t.Errorf("expected 404 for non-public game as non-admin, got %T", resp)
+ }
+}
+
+func TestGetGame_PublicGameSuccess(t *testing.T) {
+ now := time.Now()
+ h := Handler{
+ q: &mockQuerier{
+ getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) {
+ return db.GetGameByIDRow{
+ GameID: 1,
+ IsPublic: true,
+ Language: "php",
+ DisplayName: "Test Game",
+ DurationSeconds: 300,
+ StartedAt: pgtype.Timestamp{Time: now, Valid: true},
+ GameType: "golf",
+ ProblemID: 10,
+ Title: "Test Problem",
+ Description: "desc",
+ SampleCode: "<?php",
+ }, nil
+ },
+ listMainPlayersFunc: func(_ context.Context, _ []int32) ([]db.ListMainPlayersRow, error) {
+ return []db.ListMainPlayersRow{
+ {UserID: 1, Username: "player1", DisplayName: "Player 1", IsAdmin: false},
+ }, nil
+ },
+ },
+ txm: &mockTxManager{},
+ hub: &mockGameHub{},
+ auth: &mockAuthenticator{},
+ conf: &config.Config{},
+ }
+ user := &db.User{UserID: 1, IsAdmin: false}
+ resp, err := h.GetGame(context.Background(), GetGameRequestObject{GameID: 1}, user)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ okResp, ok := resp.(GetGame200JSONResponse)
+ if !ok {
+ t.Fatalf("expected 200 response, got %T", resp)
+ }
+ if okResp.Game.GameID != 1 {
+ t.Errorf("expected game ID 1, got %d", okResp.Game.GameID)
+ }
+ if len(okResp.Game.MainPlayers) != 1 {
+ t.Fatalf("expected 1 main player, got %d", len(okResp.Game.MainPlayers))
+ }
+ if okResp.Game.MainPlayers[0].Username != "player1" {
+ t.Errorf("expected username 'player1', got %q", okResp.Game.MainPlayers[0].Username)
+ }
+}
+
func TestPostLogin_AuthFailure(t *testing.T) {
h := Handler{
q: &mockQuerier{},
diff --git a/backend/auth/auth_test.go b/backend/auth/auth_test.go
new file mode 100644
index 0000000..00d4527
--- /dev/null
+++ b/backend/auth/auth_test.go
@@ -0,0 +1,125 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/jackc/pgx/v5"
+ "golang.org/x/crypto/bcrypt"
+
+ "albatross-2026-backend/db"
+)
+
+type mockQuerier struct {
+ db.Querier
+ getUserAuthByUsernameFunc func(ctx context.Context, username string) (db.GetUserAuthByUsernameRow, error)
+}
+
+func (m *mockQuerier) GetUserAuthByUsername(ctx context.Context, username string) (db.GetUserAuthByUsernameRow, error) {
+ if m.getUserAuthByUsernameFunc != nil {
+ return m.getUserAuthByUsernameFunc(ctx, username)
+ }
+ return db.GetUserAuthByUsernameRow{}, pgx.ErrNoRows
+}
+
+type mockTxManager struct{}
+
+func (m *mockTxManager) RunInTx(_ context.Context, fn func(q db.Querier) error) error {
+ return fn(&mockQuerier{})
+}
+
+func TestLogin_PasswordAuth_Success(t *testing.T) {
+ hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost)
+ if err != nil {
+ t.Fatalf("failed to generate hash: %v", err)
+ }
+ hashStr := string(hash)
+
+ a := NewAuthenticator(
+ &mockQuerier{
+ getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) {
+ return db.GetUserAuthByUsernameRow{
+ UserID: 42,
+ AuthType: "password",
+ PasswordHash: &hashStr,
+ }, nil
+ },
+ },
+ &mockTxManager{},
+ )
+
+ userID, err := a.Login(context.Background(), "testuser", "correct-password")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if userID != 42 {
+ t.Errorf("expected userID 42, got %d", userID)
+ }
+}
+
+func TestLogin_PasswordAuth_WrongPassword(t *testing.T) {
+ hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost)
+ if err != nil {
+ t.Fatalf("failed to generate hash: %v", err)
+ }
+ hashStr := string(hash)
+
+ a := NewAuthenticator(
+ &mockQuerier{
+ getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) {
+ return db.GetUserAuthByUsernameRow{
+ UserID: 42,
+ AuthType: "password",
+ PasswordHash: &hashStr,
+ }, nil
+ },
+ },
+ &mockTxManager{},
+ )
+
+ _, err = a.Login(context.Background(), "testuser", "wrong-password")
+ if err == nil {
+ t.Fatal("expected error for wrong password, got nil")
+ }
+}
+
+func TestLogin_PasswordAuth_NilHash(t *testing.T) {
+ a := NewAuthenticator(
+ &mockQuerier{
+ getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) {
+ return db.GetUserAuthByUsernameRow{
+ UserID: 42,
+ AuthType: "password",
+ PasswordHash: nil,
+ }, nil
+ },
+ },
+ &mockTxManager{},
+ )
+
+ _, err := a.Login(context.Background(), "testuser", "any")
+ if err == nil {
+ t.Fatal("expected error for nil password hash, got nil")
+ }
+ if err.Error() != "inconsistent data: password auth type but no password hash" {
+ t.Errorf("unexpected error message: %s", err.Error())
+ }
+}
+
+func TestLogin_DBError(t *testing.T) {
+ dbErr := errors.New("database connection failed")
+ a := NewAuthenticator(
+ &mockQuerier{
+ getUserAuthByUsernameFunc: func(_ context.Context, _ string) (db.GetUserAuthByUsernameRow, error) {
+ return db.GetUserAuthByUsernameRow{}, dbErr
+ },
+ },
+ &mockTxManager{},
+ )
+
+ _, err := a.Login(context.Background(), "testuser", "any")
+ if !errors.Is(err, dbErr) {
+ t.Errorf("expected db error, got: %v", err)
+ }
+}
diff --git a/backend/auth/session_test.go b/backend/auth/session_test.go
new file mode 100644
index 0000000..2a47739
--- /dev/null
+++ b/backend/auth/session_test.go
@@ -0,0 +1,47 @@
+package auth
+
+import (
+ "testing"
+)
+
+func TestGenerateSessionID(t *testing.T) {
+ id, err := GenerateSessionID()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // 32 bytes → 64 hex characters
+ if len(id) != 64 {
+ t.Errorf("expected session ID length 64, got %d", len(id))
+ }
+}
+
+func TestGenerateSessionID_Unique(t *testing.T) {
+ id1, err := GenerateSessionID()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ id2, err := GenerateSessionID()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if id1 == id2 {
+ t.Error("expected unique session IDs, got identical values")
+ }
+}
+
+func TestHashSessionID(t *testing.T) {
+ raw := "abc123"
+ hashed := HashSessionID(raw)
+ // SHA-256 produces 32 bytes → 64 hex characters
+ if len(hashed) != 64 {
+ t.Errorf("expected hash length 64, got %d", len(hashed))
+ }
+ // Same input should produce same hash
+ if hashed != HashSessionID(raw) {
+ t.Error("expected deterministic hash")
+ }
+ // Different input should produce different hash
+ if hashed == HashSessionID("different") {
+ t.Error("expected different hashes for different inputs")
+ }
+}
diff --git a/backend/config/config_test.go b/backend/config/config_test.go
new file mode 100644
index 0000000..5110e0c
--- /dev/null
+++ b/backend/config/config_test.go
@@ -0,0 +1,139 @@
+package config
+
+import (
+ "testing"
+)
+
+func TestNewConfigFromEnv_AllSet(t *testing.T) {
+ t.Setenv("ALBATROSS_DB_HOST", "localhost")
+ t.Setenv("ALBATROSS_DB_PORT", "5432")
+ t.Setenv("ALBATROSS_DB_USER", "user")
+ t.Setenv("ALBATROSS_DB_PASSWORD", "pass")
+ t.Setenv("ALBATROSS_DB_NAME", "testdb")
+ t.Setenv("ALBATROSS_BASE_PATH", "/app")
+ t.Setenv("ALBATROSS_IS_LOCAL", "1")
+
+ conf, err := NewConfigFromEnv()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if conf.DBHost != "localhost" {
+ t.Errorf("expected DBHost 'localhost', got %q", conf.DBHost)
+ }
+ if conf.DBPort != "5432" {
+ t.Errorf("expected DBPort '5432', got %q", conf.DBPort)
+ }
+ if conf.DBUser != "user" {
+ t.Errorf("expected DBUser 'user', got %q", conf.DBUser)
+ }
+ if conf.DBPassword != "pass" {
+ t.Errorf("expected DBPassword 'pass', got %q", conf.DBPassword)
+ }
+ if conf.DBName != "testdb" {
+ t.Errorf("expected DBName 'testdb', got %q", conf.DBName)
+ }
+ if conf.BasePath != "/app" {
+ t.Errorf("expected BasePath '/app', got %q", conf.BasePath)
+ }
+ if !conf.IsLocal {
+ t.Error("expected IsLocal true")
+ }
+}
+
+func TestNewConfigFromEnv_IsLocalFalse(t *testing.T) {
+ t.Setenv("ALBATROSS_DB_HOST", "localhost")
+ t.Setenv("ALBATROSS_DB_PORT", "5432")
+ t.Setenv("ALBATROSS_DB_USER", "user")
+ t.Setenv("ALBATROSS_DB_PASSWORD", "pass")
+ t.Setenv("ALBATROSS_DB_NAME", "testdb")
+ t.Setenv("ALBATROSS_BASE_PATH", "/app")
+ // ALBATROSS_IS_LOCAL not set
+
+ conf, err := NewConfigFromEnv()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if conf.IsLocal {
+ t.Error("expected IsLocal false when env not set")
+ }
+}
+
+func TestNewConfigFromEnv_MissingRequired(t *testing.T) {
+ tests := []struct {
+ name string
+ envVars map[string]string
+ }{
+ {
+ name: "missing DB_HOST",
+ envVars: map[string]string{
+ "ALBATROSS_DB_PORT": "5432",
+ "ALBATROSS_DB_USER": "user",
+ "ALBATROSS_DB_PASSWORD": "pass",
+ "ALBATROSS_DB_NAME": "testdb",
+ "ALBATROSS_BASE_PATH": "/app",
+ },
+ },
+ {
+ name: "missing DB_PORT",
+ envVars: map[string]string{
+ "ALBATROSS_DB_HOST": "localhost",
+ "ALBATROSS_DB_USER": "user",
+ "ALBATROSS_DB_PASSWORD": "pass",
+ "ALBATROSS_DB_NAME": "testdb",
+ "ALBATROSS_BASE_PATH": "/app",
+ },
+ },
+ {
+ name: "missing DB_USER",
+ envVars: map[string]string{
+ "ALBATROSS_DB_HOST": "localhost",
+ "ALBATROSS_DB_PORT": "5432",
+ "ALBATROSS_DB_PASSWORD": "pass",
+ "ALBATROSS_DB_NAME": "testdb",
+ "ALBATROSS_BASE_PATH": "/app",
+ },
+ },
+ {
+ name: "missing DB_PASSWORD",
+ envVars: map[string]string{
+ "ALBATROSS_DB_HOST": "localhost",
+ "ALBATROSS_DB_PORT": "5432",
+ "ALBATROSS_DB_USER": "user",
+ "ALBATROSS_DB_NAME": "testdb",
+ "ALBATROSS_BASE_PATH": "/app",
+ },
+ },
+ {
+ name: "missing DB_NAME",
+ envVars: map[string]string{
+ "ALBATROSS_DB_HOST": "localhost",
+ "ALBATROSS_DB_PORT": "5432",
+ "ALBATROSS_DB_USER": "user",
+ "ALBATROSS_DB_PASSWORD": "pass",
+ "ALBATROSS_BASE_PATH": "/app",
+ },
+ },
+ {
+ name: "missing BASE_PATH",
+ envVars: map[string]string{
+ "ALBATROSS_DB_HOST": "localhost",
+ "ALBATROSS_DB_PORT": "5432",
+ "ALBATROSS_DB_USER": "user",
+ "ALBATROSS_DB_PASSWORD": "pass",
+ "ALBATROSS_DB_NAME": "testdb",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for k, v := range tt.envVars {
+ t.Setenv(k, v)
+ }
+ _, err := NewConfigFromEnv()
+ if err == nil {
+ t.Error("expected error for missing env var, got nil")
+ }
+ })
+ }
+}
diff --git a/backend/game/hub_test.go b/backend/game/hub_test.go
index a8fad58..59ce996 100644
--- a/backend/game/hub_test.go
+++ b/backend/game/hub_test.go
@@ -1,6 +1,253 @@
package game
-import "testing"
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "albatross-2026-backend/db"
+ "albatross-2026-backend/taskqueue"
+)
+
+// mockTaskQueue implements TaskQueueInterface for testing.
+type mockTaskQueue struct {
+ enqueued []taskqueue.TaskPayloadRunTestcase
+ err error
+}
+
+func (m *mockTaskQueue) EnqueueTaskRunTestcase(gameID, userID, submissionID, testcaseID int, language, code, stdin, stdout string) error {
+ if m.err != nil {
+ return m.err
+ }
+ m.enqueued = append(m.enqueued, taskqueue.TaskPayloadRunTestcase{
+ GameID: gameID,
+ UserID: userID,
+ SubmissionID: submissionID,
+ TestcaseID: testcaseID,
+ Language: language,
+ Code: code,
+ Stdin: stdin,
+ Stdout: stdout,
+ })
+ return nil
+}
+
+// mockQuerier implements db.Querier for testing.
+type mockQuerier struct {
+ db.Querier
+ listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error)
+ createTestcaseResultFunc func(ctx context.Context, arg db.CreateTestcaseResultParams) error
+ createTestcaseResultCalls []db.CreateTestcaseResultParams
+}
+
+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) CreateTestcaseResult(_ context.Context, arg db.CreateTestcaseResultParams) error {
+ m.createTestcaseResultCalls = append(m.createTestcaseResultCalls, arg)
+ if m.createTestcaseResultFunc != nil {
+ return m.createTestcaseResultFunc(context.Background(), arg)
+ }
+ return nil
+}
+
+func TestEnqueueTestTasks(t *testing.T) {
+ testcases := []db.Testcase{
+ {TestcaseID: 1, ProblemID: 10, Stdin: "input1", Stdout: "output1"},
+ {TestcaseID: 2, ProblemID: 10, Stdin: "input2", Stdout: "output2"},
+ }
+
+ tq := &mockTaskQueue{}
+ mq := &mockQuerier{
+ listTestcasesByGameIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) {
+ return testcases, nil
+ },
+ }
+
+ hub := &Hub{q: mq, taskQueue: tq, ctx: context.Background()}
+
+ err := hub.EnqueueTestTasks(context.Background(), 100, 1, 42, "php", "<?php echo 1;")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(tq.enqueued) != 2 {
+ t.Fatalf("expected 2 enqueued tasks, got %d", len(tq.enqueued))
+ }
+ if tq.enqueued[0].TestcaseID != 1 || tq.enqueued[1].TestcaseID != 2 {
+ t.Errorf("unexpected testcase IDs: %d, %d", tq.enqueued[0].TestcaseID, tq.enqueued[1].TestcaseID)
+ }
+ if tq.enqueued[0].SubmissionID != 100 {
+ t.Errorf("expected submission ID 100, got %d", tq.enqueued[0].SubmissionID)
+ }
+}
+
+func TestEnqueueTestTasks_QueueError(t *testing.T) {
+ queueErr := errors.New("queue full")
+ tq := &mockTaskQueue{err: queueErr}
+ mq := &mockQuerier{
+ listTestcasesByGameIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) {
+ return []db.Testcase{{TestcaseID: 1, Stdin: "in", Stdout: "out"}}, nil
+ },
+ }
+
+ hub := &Hub{q: mq, taskQueue: tq, ctx: context.Background()}
+
+ err := hub.EnqueueTestTasks(context.Background(), 100, 1, 42, "php", "code")
+ if !errors.Is(err, queueErr) {
+ t.Errorf("expected queue error, got: %v", err)
+ }
+}
+
+func TestEnqueueTestTasks_DBError(t *testing.T) {
+ dbErr := errors.New("db error")
+ tq := &mockTaskQueue{}
+ mq := &mockQuerier{
+ listTestcasesByGameIDFunc: func(_ context.Context, _ int32) ([]db.Testcase, error) {
+ return nil, dbErr
+ },
+ }
+
+ hub := &Hub{q: mq, taskQueue: tq, ctx: context.Background()}
+
+ err := hub.EnqueueTestTasks(context.Background(), 100, 1, 42, "php", "code")
+ if !errors.Is(err, dbErr) {
+ t.Errorf("expected db error, got: %v", err)
+ }
+}
+
+func TestProcessTaskResultRunTestcase_Success_Correct(t *testing.T) {
+ mq := &mockQuerier{}
+ hub := &Hub{q: mq, ctx: context.Background()}
+
+ result := &taskqueue.TaskResultRunTestcase{
+ TaskPayload: &taskqueue.TaskPayloadRunTestcase{
+ SubmissionID: 1,
+ TestcaseID: 2,
+ Stdout: "expected output",
+ },
+ Status: "success",
+ Stdout: "expected output",
+ Stderr: "",
+ }
+
+ err := hub.processTaskResultRunTestcase(result)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(mq.createTestcaseResultCalls) != 1 {
+ t.Fatalf("expected 1 call, got %d", len(mq.createTestcaseResultCalls))
+ }
+ if mq.createTestcaseResultCalls[0].Status != "success" {
+ t.Errorf("expected status 'success', got %q", mq.createTestcaseResultCalls[0].Status)
+ }
+}
+
+func TestProcessTaskResultRunTestcase_Success_WrongAnswer(t *testing.T) {
+ mq := &mockQuerier{}
+ hub := &Hub{q: mq, ctx: context.Background()}
+
+ result := &taskqueue.TaskResultRunTestcase{
+ TaskPayload: &taskqueue.TaskPayloadRunTestcase{
+ SubmissionID: 1,
+ TestcaseID: 2,
+ Stdout: "expected",
+ },
+ Status: "success",
+ Stdout: "wrong",
+ Stderr: "",
+ }
+
+ err := hub.processTaskResultRunTestcase(result)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(mq.createTestcaseResultCalls) != 1 {
+ t.Fatalf("expected 1 call, got %d", len(mq.createTestcaseResultCalls))
+ }
+ if mq.createTestcaseResultCalls[0].Status != "wrong_answer" {
+ t.Errorf("expected status 'wrong_answer', got %q", mq.createTestcaseResultCalls[0].Status)
+ }
+}
+
+func TestProcessTaskResultRunTestcase_NonSuccess(t *testing.T) {
+ mq := &mockQuerier{}
+ hub := &Hub{q: mq, ctx: context.Background()}
+
+ result := &taskqueue.TaskResultRunTestcase{
+ TaskPayload: &taskqueue.TaskPayloadRunTestcase{
+ SubmissionID: 1,
+ TestcaseID: 2,
+ Stdout: "expected",
+ },
+ Status: "timeout",
+ Stdout: "",
+ Stderr: "execution timed out",
+ }
+
+ err := hub.processTaskResultRunTestcase(result)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(mq.createTestcaseResultCalls) != 1 {
+ t.Fatalf("expected 1 call, got %d", len(mq.createTestcaseResultCalls))
+ }
+ if mq.createTestcaseResultCalls[0].Status != "timeout" {
+ t.Errorf("expected status 'timeout', got %q", mq.createTestcaseResultCalls[0].Status)
+ }
+}
+
+func TestProcessTaskResultRunTestcase_TaskError(t *testing.T) {
+ mq := &mockQuerier{}
+ hub := &Hub{q: mq, ctx: context.Background()}
+
+ taskErr := errors.New("worker crashed")
+ result := &taskqueue.TaskResultRunTestcase{
+ TaskPayload: &taskqueue.TaskPayloadRunTestcase{
+ SubmissionID: 1,
+ TestcaseID: 2,
+ },
+ Err: taskErr,
+ }
+
+ err := hub.processTaskResultRunTestcase(result)
+ if !errors.Is(err, taskErr) {
+ t.Errorf("expected task error, got: %v", err)
+ }
+ if len(mq.createTestcaseResultCalls) != 0 {
+ t.Error("expected no DB calls when task has error")
+ }
+}
+
+func TestNormalizeTestcaseResultOutput(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"empty", "", ""},
+ {"no changes needed", "hello", "hello"},
+ {"trim spaces", " hello ", "hello"},
+ {"CRLF to LF", "line1\r\nline2", "line1\nline2"},
+ {"CR to LF", "line1\rline2", "line1\nline2"},
+ {"mixed", " line1\r\nline2\r ", "line1\nline2"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeTestcaseResultOutput(tt.input)
+ if got != tt.want {
+ t.Errorf("normalizeTestcaseResultOutput(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
func TestCalcCodeSize_PHP(t *testing.T) {
hub := &Hub{}
diff --git a/backend/justfile b/backend/justfile
index 1897cab..4a3c14e 100644
--- a/backend/justfile
+++ b/backend/justfile
@@ -7,3 +7,5 @@ test:
gen:
go generate ./...
+
+ci: check test
diff --git a/backend/ratelimit/ratelimit_test.go b/backend/ratelimit/ratelimit_test.go
new file mode 100644
index 0000000..65878c4
--- /dev/null
+++ b/backend/ratelimit/ratelimit_test.go
@@ -0,0 +1,123 @@
+package ratelimit
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/labstack/echo/v4"
+ "golang.org/x/time/rate"
+)
+
+func TestGetLimiter_SameIP(t *testing.T) {
+ rl := &IPRateLimiter{
+ rate: rate.Limit(10),
+ burst: 1,
+ }
+
+ l1 := rl.getLimiter("192.168.1.1")
+ l2 := rl.getLimiter("192.168.1.1")
+ if l1 != l2 {
+ t.Error("expected same limiter for same IP")
+ }
+}
+
+func TestGetLimiter_DifferentIP(t *testing.T) {
+ rl := &IPRateLimiter{
+ rate: rate.Limit(10),
+ burst: 1,
+ }
+
+ l1 := rl.getLimiter("192.168.1.1")
+ l2 := rl.getLimiter("192.168.1.2")
+ if l1 == l2 {
+ t.Error("expected different limiters for different IPs")
+ }
+}
+
+func TestLoginRateLimitMiddleware_AllowsNonLogin(t *testing.T) {
+ rl := &IPRateLimiter{
+ rate: rate.Limit(0), // zero rate = deny all
+ burst: 0,
+ }
+
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodGet, "/api/games", nil)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ c.SetPath("/api/games")
+
+ handler := LoginRateLimitMiddleware(rl)(func(c echo.Context) error {
+ return c.NoContent(http.StatusOK)
+ })
+
+ if err := handler(c); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", rec.Code)
+ }
+}
+
+func TestLoginRateLimitMiddleware_BlocksExcessiveLogin(t *testing.T) {
+ rl := &IPRateLimiter{
+ rate: rate.Limit(0.001), // very low rate
+ burst: 1,
+ }
+
+ e := echo.New()
+
+ // First request should succeed (burst = 1)
+ req1 := httptest.NewRequest(http.MethodPost, "/api/login", nil)
+ rec1 := httptest.NewRecorder()
+ c1 := e.NewContext(req1, rec1)
+ c1.SetPath("/api/login")
+
+ handler := LoginRateLimitMiddleware(rl)(func(c echo.Context) error {
+ return c.NoContent(http.StatusOK)
+ })
+
+ if err := handler(c1); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec1.Code != http.StatusOK {
+ t.Errorf("first request: expected 200, got %d", rec1.Code)
+ }
+
+ // Second request should be rate limited
+ req2 := httptest.NewRequest(http.MethodPost, "/api/login", nil)
+ rec2 := httptest.NewRecorder()
+ c2 := e.NewContext(req2, rec2)
+ c2.SetPath("/api/login")
+
+ if err := handler(c2); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec2.Code != http.StatusTooManyRequests {
+ t.Errorf("second request: expected 429, got %d", rec2.Code)
+ }
+}
+
+func TestLoginRateLimitMiddleware_AllowsNonPostLogin(t *testing.T) {
+ rl := &IPRateLimiter{
+ rate: rate.Limit(0),
+ burst: 0,
+ }
+
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodGet, "/api/login", nil)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+ c.SetPath("/api/login")
+
+ handler := LoginRateLimitMiddleware(rl)(func(c echo.Context) error {
+ return c.NoContent(http.StatusOK)
+ })
+
+ if err := handler(c); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rec.Code != http.StatusOK {
+ t.Errorf("expected 200 for GET /login, got %d", rec.Code)
+ }
+}