diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-15 22:13:50 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-15 22:16:22 +0900 |
| commit | 5ed369a6c70707543fd5ec9a13c79851fdfc5d6c (patch) | |
| tree | e5678d6d88fab3ac0ae8c05b85236f3e7d5eddfd /backend/game | |
| parent | 87e9f5ed48af3a8dca5f6373ae900336f285eef5 (diff) | |
| download | phperkaigi-2026-albatross-5ed369a6c70707543fd5ec9a13c79851fdfc5d6c.tar.gz phperkaigi-2026-albatross-5ed369a6c70707543fd5ec9a13c79851fdfc5d6c.tar.zst phperkaigi-2026-albatross-5ed369a6c70707543fd5ec9a13c79851fdfc5d6c.zip | |
refactor(backend): introduce DI interfaces for testability
Replace concrete *db.Queries and *pgxpool.Pool dependencies with
db.Querier and db.TxManager interfaces across all handlers, game hub,
and auth. This enables unit testing with mocks.
- Enable sqlc emit_interface to generate Querier interface
- Add TxManager abstraction to encapsulate transactions
- Convert auth package-level functions to Authenticator struct
- Add TaskQueueInterface/TaskWorkerInterface for game.Hub
- Add initial unit tests for game logic and API handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend/game')
| -rw-r--r-- | backend/game/hub.go | 70 | ||||
| -rw-r--r-- | backend/game/hub_test.go | 100 |
2 files changed, 133 insertions, 37 deletions
diff --git a/backend/game/hub.go b/backend/game/hub.go index d918543..9c193f2 100644 --- a/backend/game/hub.go +++ b/backend/game/hub.go @@ -7,25 +7,31 @@ import ( "regexp" "strings" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "albatross-2026-backend/db" "albatross-2026-backend/taskqueue" ) +type TaskQueueInterface interface { + EnqueueTaskRunTestcase(gameID, userID, submissionID, testcaseID int, language, code, stdin, stdout string) error +} + +type TaskWorkerInterface interface { + Run() error + Results() chan taskqueue.TaskResult +} + type Hub struct { - q *db.Queries - pool *pgxpool.Pool + q db.Querier + txm db.TxManager ctx context.Context - taskQueue *taskqueue.Queue - taskWorker *taskqueue.WorkerServer + taskQueue TaskQueueInterface + taskWorker TaskWorkerInterface } -func NewGameHub(q *db.Queries, pool *pgxpool.Pool, taskQueue *taskqueue.Queue, taskWorker *taskqueue.WorkerServer) *Hub { +func NewGameHub(q db.Querier, txm db.TxManager, taskQueue TaskQueueInterface, taskWorker TaskWorkerInterface) *Hub { return &Hub{ q: q, - pool: pool, + txm: txm, ctx: context.Background(), taskQueue: taskQueue, taskWorker: taskWorker, @@ -104,40 +110,30 @@ func (hub *Hub) processTaskResults() { } func (hub *Hub) updateSubmissionAndGameState(taskResult *taskqueue.TaskResultRunTestcase, aggregatedStatus string) error { - tx, err := hub.pool.Begin(hub.ctx) - if err != nil { - return err - } - defer func() { - if err := tx.Rollback(hub.ctx); err != nil && err != pgx.ErrTxClosed { - slog.Error("failed to rollback transaction", "error", err) + return hub.txm.RunInTx(hub.ctx, func(qtx db.Querier) error { + if err := qtx.UpdateSubmissionStatus(hub.ctx, db.UpdateSubmissionStatusParams{ + SubmissionID: int32(taskResult.TaskPayload.SubmissionID), + Status: aggregatedStatus, + }); err != nil { + return err } - }() - - qtx := hub.q.WithTx(tx) - if err := qtx.UpdateSubmissionStatus(hub.ctx, db.UpdateSubmissionStatusParams{ - SubmissionID: int32(taskResult.TaskPayload.SubmissionID), - Status: aggregatedStatus, - }); err != nil { - return err - } - if err := qtx.UpdateGameStateStatus(hub.ctx, db.UpdateGameStateStatusParams{ - GameID: int32(taskResult.TaskPayload.GameID), - UserID: int32(taskResult.TaskPayload.UserID), - Status: aggregatedStatus, - }); err != nil { - return err - } - if aggregatedStatus == "success" { - if err := qtx.SyncGameStateBestScoreSubmission(hub.ctx, db.SyncGameStateBestScoreSubmissionParams{ + if err := qtx.UpdateGameStateStatus(hub.ctx, db.UpdateGameStateStatusParams{ GameID: int32(taskResult.TaskPayload.GameID), UserID: int32(taskResult.TaskPayload.UserID), + Status: aggregatedStatus, }); err != nil { return err } - } - - return tx.Commit(hub.ctx) + if aggregatedStatus == "success" { + if err := qtx.SyncGameStateBestScoreSubmission(hub.ctx, db.SyncGameStateBestScoreSubmissionParams{ + GameID: int32(taskResult.TaskPayload.GameID), + UserID: int32(taskResult.TaskPayload.UserID), + }); err != nil { + return err + } + } + return nil + }) } func (hub *Hub) processTaskResultRunTestcase( diff --git a/backend/game/hub_test.go b/backend/game/hub_test.go new file mode 100644 index 0000000..a8fad58 --- /dev/null +++ b/backend/game/hub_test.go @@ -0,0 +1,100 @@ +package game + +import "testing" + +func TestCalcCodeSize_PHP(t *testing.T) { + hub := &Hub{} + tests := []struct { + name string + code string + language string + want int + }{ + { + name: "simple php code", + code: "<?php echo 1;", + language: "php", + want: 6, // "echo1;" after stripping whitespace and "<?php" + }, + { + name: "php with short open tag", + code: "<? echo 1;", + language: "php", + want: 6, // "echo1;" after stripping whitespace and "<?" + }, + { + name: "php with closing tag", + code: "<?php echo 1; ?>", + language: "php", + want: 6, // "echo1;" after stripping whitespace, "<?php", and "?>" + }, + { + name: "php with whitespace", + code: "<?php echo 1 ; ?>", + language: "php", + want: 6, + }, + { + name: "non-php language", + code: "print(1)", + language: "swift", + want: 8, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hub.CalcCodeSize(tt.code, tt.language) + if got != tt.want { + t.Errorf("CalcCodeSize(%q, %q) = %d, want %d", tt.code, tt.language, got, tt.want) + } + }) + } +} + +func TestIsTestcaseResultCorrect(t *testing.T) { + tests := []struct { + name string + expected string + actual string + want bool + }{ + { + name: "exact match", + expected: "hello", + actual: "hello", + want: true, + }, + { + name: "trailing newline ignored", + expected: "hello\n", + actual: "hello", + want: true, + }, + { + name: "CRLF normalized", + expected: "hello\r\n", + actual: "hello\n", + want: true, + }, + { + name: "mismatch", + expected: "hello", + actual: "world", + want: false, + }, + { + name: "multiline match", + expected: "line1\nline2", + actual: "line1\nline2\n", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTestcaseResultCorrect(tt.expected, tt.actual) + if got != tt.want { + t.Errorf("isTestcaseResultCorrect(%q, %q) = %v, want %v", tt.expected, tt.actual, got, tt.want) + } + }) + } +} |
