aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/game
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-15 22:13:50 +0900
committernsfisis <nsfisis@gmail.com>2026-02-15 22:16:22 +0900
commit5ed369a6c70707543fd5ec9a13c79851fdfc5d6c (patch)
treee5678d6d88fab3ac0ae8c05b85236f3e7d5eddfd /backend/game
parent87e9f5ed48af3a8dca5f6373ae900336f285eef5 (diff)
downloadphperkaigi-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.go70
-rw-r--r--backend/game/hub_test.go100
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)
+ }
+ })
+ }
+}