diff --git a/backend/package-lock.json b/backend/package-lock.json index fdcad95..78df6aa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,7 @@ "devDependencies": { "jest": "^30.4.2", "nodemon": "^3.1.11", + "socket.io-client": "^4.8.3", "supertest": "^7.2.2" }, "engines": { @@ -61,6 +62,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -546,31 +548,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -578,7 +555,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2234,6 +2210,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2932,6 +2909,20 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -5388,6 +5379,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -6095,6 +6087,22 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", @@ -6934,9 +6942,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6954,6 +6962,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index c9daaed..81e528c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,9 +24,9 @@ "@supabase/supabase-js": "^2.95.3", "axios": "^1.6.0", "cors": "^2.8.5", - "helmet": "^8.0.0", "dotenv": "^16.4.5", "express": "^5.2.1", + "helmet": "^8.0.0", "jsonwebtoken": "^9.0.3", "pg": "^8.23.0", "socket.io": "^4.8.3", @@ -35,6 +35,7 @@ "devDependencies": { "jest": "^30.4.2", "nodemon": "^3.1.11", + "socket.io-client": "^4.8.3", "supertest": "^7.2.2" } } diff --git a/backend/server.js b/backend/server.js index e53f0d9..956c5f3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -215,4 +215,6 @@ cronService.start(); server.listen(PORT, "0.0.0.0", () => { console.log(`Chesster backend running on port ${PORT}`); -}); \ No newline at end of file +}); + +module.exports = { app, server, io }; \ No newline at end of file diff --git a/backend/tests/gameRoutes.test.js b/backend/tests/gameRoutes.test.js new file mode 100644 index 0000000..c87870d --- /dev/null +++ b/backend/tests/gameRoutes.test.js @@ -0,0 +1,693 @@ +/** + * Supertest integration tests for /api/games and /api/escrow endpoints. + */ + +const request = require("supertest"); +const express = require("express"); + +jest.mock("../models/gameModel", () => ({ + createGame: jest.fn(), + joinGame: jest.fn(), + getGame: jest.fn(), + getPendingGames: jest.fn(), + makeMove: jest.fn(), + getMoves: jest.fn(), + resignGame: jest.fn(), + offerDraw: jest.fn(), + acceptDraw: jest.fn(), + getChatMessages: jest.fn(), + getGameHistory: jest.fn(), + requestUndoMove: jest.fn(), + acceptUndoMove: jest.fn(), + rejectUndoMove: jest.fn(), +})); + +jest.mock("../services/timerService", () => ({ + getTimeControls: jest.fn(), + startClock: jest.fn(), + getClockState: jest.fn(), + applyMove: jest.fn(), + clearTimer: jest.fn(), + clearClock: jest.fn(), +})); + +jest.mock("../services/escrowService", () => ({ + init: jest.fn(), + getMatch: jest.fn(), + createMatch: jest.fn(), + joinMatch: jest.fn(), + resolveMatch: jest.fn(), + DRAW_ADDRESS: "DRAW_ADDRESS_PLACEHOLDER", +})); + +const gameModel = require("../models/gameModel"); +const timerService = require("../services/timerService"); +const escrowService = require("../services/escrowService"); +const gameRoutes = require("../routes/gameRoutes"); +const escrowRoutes = require("../routes/escrowRoutes"); + +function buildApp() { + const app = express(); + app.use(express.json()); + + const mockIo = { to: jest.fn().mockReturnThis(), emit: jest.fn() }; + app.set("io", mockIo); + + app.use("/api", gameRoutes); + app.use("/api/escrow", escrowRoutes); + return { app, mockIo }; +} + +describe("Game & Escrow Routes", () => { + let app; + let mockIo; + + beforeEach(() => { + jest.clearAllMocks(); + ({ app, mockIo } = buildApp()); + }); + + // --------------------------------------------------------------------- + // POST /api/games + // --------------------------------------------------------------------- + describe("POST /api/games", () => { + test("creates a game and returns 201 with the created game", async () => { + const created = { id: 1, game_code: "ABC123", status: "waiting" }; + gameModel.createGame.mockResolvedValue(created); + + const res = await request(app) + .post("/api/games") + .send({ gameType: "wagered", wagerAmount: 100, playerWhiteAddress: "P1" }); + + expect(res.status).toBe(201); + expect(res.body).toEqual({ success: true, data: created }); + expect(gameModel.createGame).toHaveBeenCalledWith( + "wagered", + 100, + "P1", + 600, + null, + null, + 0, + ); + }); + + test("applies defaults for optional fields", async () => { + gameModel.createGame.mockResolvedValue({ id: 2 }); + + await request(app).post("/api/games").send({}); + + expect(gameModel.createGame).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + 600, + null, + null, + 0, + ); + }); + + test("returns 500 when game creation fails", async () => { + gameModel.createGame.mockRejectedValue(new Error("db unavailable")); + + const res = await request(app).post("/api/games").send({}); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ success: false, error: "db unavailable" }); + }); + }); + + // --------------------------------------------------------------------- + // GET /api/time-controls + // --------------------------------------------------------------------- + describe("GET /api/time-controls", () => { + test("returns the supported time control presets", async () => { + const presets = { blitz: 300, rapid: 600 }; + timerService.getTimeControls.mockReturnValue(presets); + + const res = await request(app).get("/api/time-controls"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: presets }); + }); + }); + + // --------------------------------------------------------------------- + // GET /api/games/pending + // --------------------------------------------------------------------- + describe("GET /api/games/pending", () => { + test("returns pending games", async () => { + gameModel.getPendingGames.mockResolvedValue([{ game_code: "P1" }]); + + const res = await request(app).get("/api/games/pending"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: [{ game_code: "P1" }] }); + }); + + test("returns 500 when the model throws", async () => { + gameModel.getPendingGames.mockRejectedValue(new Error("boom")); + + const res = await request(app).get("/api/games/pending"); + + expect(res.status).toBe(500); + expect(res.body.success).toBe(false); + }); + }); + + // --------------------------------------------------------------------- + // GET /api/games (history) + // --------------------------------------------------------------------- + describe("GET /api/games", () => { + test("passes query filters through and returns history", async () => { + gameModel.getGameHistory.mockResolvedValue({ data: [], total: 0 }); + + const res = await request(app) + .get("/api/games") + .query({ playerAddress: "P1", status: "completed", page: "2", pageSize: "10" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: [], total: 0 }); + expect(gameModel.getGameHistory).toHaveBeenCalledWith({ + playerAddress: "P1", + status: "completed", + dateFrom: null, + dateTo: null, + page: "2", + pageSize: "10", + sortBy: "created_at", + sortOrder: "desc", + }); + }); + + test("uses defaults when no query params are given", async () => { + gameModel.getGameHistory.mockResolvedValue({ data: [], total: 0 }); + + await request(app).get("/api/games"); + + expect(gameModel.getGameHistory).toHaveBeenCalledWith({ + playerAddress: null, + status: null, + dateFrom: null, + dateTo: null, + page: 1, + pageSize: 20, + sortBy: "created_at", + sortOrder: "desc", + }); + }); + + test("returns 500 on failure", async () => { + gameModel.getGameHistory.mockRejectedValue(new Error("query failed")); + + const res = await request(app).get("/api/games"); + + expect(res.status).toBe(500); + }); + }); + + // --------------------------------------------------------------------- + // POST /api/games/:gameCode/join + // --------------------------------------------------------------------- + describe("POST /api/games/:gameCode/join", () => { + test("joins an active game, starts the clock, and broadcasts an update", async () => { + const game = { + game_code: "ABC123", + status: "active", + time_control_preset: "blitz", + time_control_seconds: 300, + time_increment_seconds: 0, + current_turn: "white", + }; + gameModel.joinGame.mockResolvedValue(game); + timerService.startClock.mockReturnValue({ white: 300000, black: 300000 }); + + const res = await request(app) + .post("/api/games/ABC123/join") + .send({ playerColor: "black", playerAddress: "P2" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.clock).toEqual({ white: 300000, black: 300000 }); + expect(timerService.startClock).toHaveBeenCalledWith("ABC123", { + preset: "blitz", + baseSeconds: 300, + incrementSeconds: 0, + turn: "white", + }); + expect(mockIo.to).toHaveBeenCalledWith("ABC123"); + expect(mockIo.emit).toHaveBeenCalledWith( + "game-update", + expect.objectContaining({ game_code: "ABC123" }), + ); + }); + + test("does not start a clock for a non-active game", async () => { + gameModel.joinGame.mockResolvedValue({ game_code: "ABC123", status: "waiting" }); + + const res = await request(app) + .post("/api/games/ABC123/join") + .send({ playerColor: "black", playerAddress: "P2" }); + + expect(res.status).toBe(200); + expect(res.body.data.clock).toBeNull(); + expect(timerService.startClock).not.toHaveBeenCalled(); + }); + + test("returns 400 when the game code does not exist", async () => { + gameModel.joinGame.mockRejectedValue(new Error("Game not found")); + + const res = await request(app) + .post("/api/games/DOESNOTEXIST/join") + .send({ playerColor: "black", playerAddress: "P2" }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ success: false, error: "Game not found" }); + }); + + test("returns 400 when the game is already full", async () => { + gameModel.joinGame.mockRejectedValue(new Error("Game already has two players")); + + const res = await request(app) + .post("/api/games/ABC123/join") + .send({ playerColor: "black", playerAddress: "P2" }); + + expect(res.status).toBe(400); + }); + }); + + // --------------------------------------------------------------------- + // GET /api/games/:gameCode + // --------------------------------------------------------------------- + describe("GET /api/games/:gameCode", () => { + test("returns the game merged with clock state", async () => { + gameModel.getGame.mockResolvedValue({ game_code: "ABC123", status: "active" }); + timerService.getClockState.mockReturnValue({ white: 100000, black: 90000 }); + + const res = await request(app).get("/api/games/ABC123"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + success: true, + data: { game_code: "ABC123", status: "active", clock: { white: 100000, black: 90000 } }, + }); + }); + + test("returns 404 for an unknown game code", async () => { + gameModel.getGame.mockRejectedValue(new Error("Game not found")); + + const res = await request(app).get("/api/games/NOPE"); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ success: false, error: "Game not found" }); + }); + }); + + // --------------------------------------------------------------------- + // POST /api/games/:gameCode/move + // --------------------------------------------------------------------- + describe("POST /api/games/:gameCode/move", () => { + test("applies a move, advances the clock, and broadcasts the update", async () => { + gameModel.getGame.mockResolvedValue({ current_turn: "white" }); + gameModel.makeMove.mockResolvedValue({ game_code: "ABC123", status: "active", current_turn: "black" }); + timerService.applyMove.mockReturnValue({ white: 295000, black: 300000 }); + + const res = await request(app) + .post("/api/games/ABC123/move") + .send({ from: "e2", to: "e4" }); + + expect(res.status).toBe(200); + expect(res.body.data.clock).toEqual({ white: 295000, black: 300000 }); + expect(timerService.applyMove).toHaveBeenCalledWith("ABC123", "white"); + expect(mockIo.to).toHaveBeenCalledWith("ABC123"); + expect(mockIo.emit).toHaveBeenCalledWith("game-update", expect.any(Object)); + }); + + test("clears timers when a move ends the game", async () => { + gameModel.getGame.mockResolvedValue({ current_turn: "white" }); + gameModel.makeMove.mockResolvedValue({ game_code: "ABC123", status: "checkmate" }); + + const res = await request(app) + .post("/api/games/ABC123/move") + .send({ from: "e2", to: "e4" }); + + expect(res.status).toBe(200); + expect(res.body.data.clock).toBeNull(); + expect(timerService.clearTimer).toHaveBeenCalledWith("ABC123"); + expect(timerService.clearClock).toHaveBeenCalledWith("ABC123"); + expect(timerService.applyMove).not.toHaveBeenCalled(); + }); + + test("returns 400 for an illegal move", async () => { + gameModel.getGame.mockResolvedValue({ current_turn: "white" }); + gameModel.makeMove.mockRejectedValue(new Error("Illegal move")); + + const res = await request(app) + .post("/api/games/ABC123/move") + .send({ from: "e2", to: "e5" }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ success: false, error: "Illegal move" }); + }); + + test("returns 400 when the game lookup itself fails", async () => { + gameModel.getGame.mockRejectedValue(new Error("Game not found")); + + const res = await request(app) + .post("/api/games/NOPE/move") + .send({ from: "e2", to: "e4" }); + + expect(res.status).toBe(400); + }); + }); + + // --------------------------------------------------------------------- + // GET /api/games/:gameCode/moves + // --------------------------------------------------------------------- + describe("GET /api/games/:gameCode/moves", () => { + test("returns the move list", async () => { + gameModel.getMoves.mockResolvedValue([{ from: "e2", to: "e4" }]); + + const res = await request(app).get("/api/games/ABC123/moves"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: [{ from: "e2", to: "e4" }] }); + }); + + test("returns 404 when the game does not exist", async () => { + gameModel.getMoves.mockRejectedValue(new Error("Game not found")); + + const res = await request(app).get("/api/games/NOPE/moves"); + + expect(res.status).toBe(404); + }); + }); + + // --------------------------------------------------------------------- + // POST /api/games/:gameCode/resign + // --------------------------------------------------------------------- + describe("POST /api/games/:gameCode/resign", () => { + test("resigns the game, clears timers, and broadcasts an update", async () => { + const game = { game_code: "ABC123", status: "resigned" }; + gameModel.resignGame.mockResolvedValue(game); + + const res = await request(app) + .post("/api/games/ABC123/resign") + .send({ playerColor: "white" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: game }); + expect(timerService.clearTimer).toHaveBeenCalledWith("ABC123"); + expect(timerService.clearClock).toHaveBeenCalledWith("ABC123"); + expect(mockIo.emit).toHaveBeenCalledWith("game-update", game); + }); + + test("returns 400 when the game cannot be resigned", async () => { + gameModel.resignGame.mockRejectedValue(new Error("Game already finished")); + + const res = await request(app) + .post("/api/games/ABC123/resign") + .send({ playerColor: "white" }); + + expect(res.status).toBe(400); + }); + }); + + // --------------------------------------------------------------------- + // Draw offer / accept + // --------------------------------------------------------------------- + describe("POST /api/games/:gameCode/draw/offer", () => { + test("records a draw offer and broadcasts it", async () => { + const game = { game_code: "ABC123", draw_offered_by: "white" }; + gameModel.offerDraw.mockResolvedValue(game); + + const res = await request(app) + .post("/api/games/ABC123/draw/offer") + .send({ playerColor: "white" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: game }); + }); + + test("returns 400 when a draw offer is invalid", async () => { + gameModel.offerDraw.mockRejectedValue(new Error("Game not active")); + + const res = await request(app) + .post("/api/games/ABC123/draw/offer") + .send({ playerColor: "white" }); + + expect(res.status).toBe(400); + }); + }); + + describe("POST /api/games/:gameCode/draw/accept", () => { + test("accepts the draw, clears timers, and broadcasts it", async () => { + const game = { game_code: "ABC123", status: "draw" }; + gameModel.acceptDraw.mockResolvedValue(game); + + const res = await request(app).post("/api/games/ABC123/draw/accept").send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: game }); + expect(timerService.clearTimer).toHaveBeenCalledWith("ABC123"); + expect(timerService.clearClock).toHaveBeenCalledWith("ABC123"); + }); + + test("returns 400 when there is no pending draw offer", async () => { + gameModel.acceptDraw.mockRejectedValue(new Error("No draw offer pending")); + + const res = await request(app).post("/api/games/ABC123/draw/accept").send({}); + + expect(res.status).toBe(400); + }); + }); + + // --------------------------------------------------------------------- + // Undo request / accept / reject + // --------------------------------------------------------------------- + describe("Undo move flow", () => { + test("POST /undo/request broadcasts the request", async () => { + const game = { game_code: "ABC123", undo_requested_by: "white" }; + gameModel.requestUndoMove.mockResolvedValue(game); + + const res = await request(app) + .post("/api/games/ABC123/undo/request") + .send({ playerColor: "white" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: game }); + }); + + test("POST /undo/request returns 400 when nothing to undo", async () => { + gameModel.requestUndoMove.mockRejectedValue(new Error("No moves to undo")); + + const res = await request(app) + .post("/api/games/ABC123/undo/request") + .send({ playerColor: "white" }); + + expect(res.status).toBe(400); + }); + + test("POST /undo/accept broadcasts the accepted undo", async () => { + const game = { game_code: "ABC123" }; + gameModel.acceptUndoMove.mockResolvedValue(game); + + const res = await request(app) + .post("/api/games/ABC123/undo/accept") + .send({ playerColor: "black" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: game }); + }); + + test("POST /undo/reject broadcasts the rejection", async () => { + const game = { game_code: "ABC123", undo_requested_by: null }; + gameModel.rejectUndoMove.mockResolvedValue(game); + + const res = await request(app).post("/api/games/ABC123/undo/reject").send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: game }); + }); + + test("POST /undo/reject returns 400 on failure", async () => { + gameModel.rejectUndoMove.mockRejectedValue(new Error("No undo request pending")); + + const res = await request(app).post("/api/games/ABC123/undo/reject").send({}); + + expect(res.status).toBe(400); + }); + }); + + // --------------------------------------------------------------------- + // GET /api/games/:gameCode/chat + // --------------------------------------------------------------------- + describe("GET /api/games/:gameCode/chat", () => { + test("returns chat messages", async () => { + gameModel.getChatMessages.mockResolvedValue([{ message: "gg" }]); + + const res = await request(app).get("/api/games/ABC123/chat"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: [{ message: "gg" }] }); + }); + + test("returns 404 when the game does not exist", async () => { + gameModel.getChatMessages.mockRejectedValue(new Error("Game not found")); + + const res = await request(app).get("/api/games/NOPE/chat"); + + expect(res.status).toBe(404); + }); + }); + + // ======================================================================= + // /api/escrow + // ======================================================================= + describe("GET /api/escrow/info", () => { + test("returns ok with the configured contract address", async () => { + const res = await request(app).get("/api/escrow/info"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + contract: process.env.ESCROW_CONTRACT_ADDRESS || null, + }); + }); + }); + + describe("GET /api/escrow/:gameCode", () => { + test("returns match details from the contract", async () => { + escrowService.getMatch.mockResolvedValue({ status: "active", wager: 100 }); + + const res = await request(app).get("/api/escrow/ABC123"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, data: { status: "active", wager: 100 } }); + expect(escrowService.getMatch).toHaveBeenCalledWith("ABC123"); + }); + + test("returns 500 when the contract call fails", async () => { + escrowService.getMatch.mockRejectedValue(new Error("RPC unavailable")); + + const res = await request(app).get("/api/escrow/ABC123"); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ success: false, error: "RPC unavailable" }); + }); + }); + + describe("POST /api/escrow/create", () => { + test("creates the on-chain match and returns the tx receipt", async () => { + escrowService.createMatch.mockResolvedValue({ hash: "0xTX", blockNumber: 42 }); + + const res = await request(app) + .post("/api/escrow/create") + .send({ gameCode: "ABC123", tokenAddress: "TOKEN1", wagerAmount: 100 }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, txHash: "0xTX", blockNumber: 42 }); + expect(escrowService.createMatch).toHaveBeenCalledWith("ABC123", "TOKEN1", 100); + }); + + test.each([ + [{ tokenAddress: "TOKEN1", wagerAmount: 100 }], + [{ gameCode: "ABC123", wagerAmount: 100 }], + [{ gameCode: "ABC123", tokenAddress: "TOKEN1" }], + ])("returns 400 when a required field is missing (%#)", async (body) => { + const res = await request(app).post("/api/escrow/create").send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "missing required fields" }); + expect(escrowService.createMatch).not.toHaveBeenCalled(); + }); + + test("returns 500 when the on-chain call fails", async () => { + escrowService.createMatch.mockRejectedValue(new Error("contract reverted")); + + const res = await request(app) + .post("/api/escrow/create") + .send({ gameCode: "ABC123", tokenAddress: "TOKEN1", wagerAmount: 100 }); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ success: false, error: "contract reverted" }); + }); + }); + + describe("POST /api/escrow/join", () => { + test("joins the on-chain match and returns the tx receipt", async () => { + escrowService.joinMatch.mockResolvedValue({ hash: "0xTX2", blockNumber: 43 }); + + const res = await request(app).post("/api/escrow/join").send({ gameCode: "ABC123" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, txHash: "0xTX2", blockNumber: 43 }); + expect(escrowService.joinMatch).toHaveBeenCalledWith("ABC123"); + }); + + test("returns 400 when gameCode is missing", async () => { + const res = await request(app).post("/api/escrow/join").send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "gameCode required" }); + expect(escrowService.joinMatch).not.toHaveBeenCalled(); + }); + + test("returns 500 when the on-chain call fails", async () => { + escrowService.joinMatch.mockRejectedValue(new Error("insufficient funds")); + + const res = await request(app).post("/api/escrow/join").send({ gameCode: "ABC123" }); + + expect(res.status).toBe(500); + }); + }); + + describe("POST /api/escrow/resolve", () => { + test("resolves the match with a winner address", async () => { + escrowService.resolveMatch.mockResolvedValue({ hash: "0xTX3", blockNumber: 44 }); + + const res = await request(app) + .post("/api/escrow/resolve") + .send({ gameCode: "ABC123", winner: "PLAYER1" }); + + expect(res.status).toBe(200); + expect(escrowService.resolveMatch).toHaveBeenCalledWith("ABC123", "PLAYER1"); + }); + + test.each(["draw", null, ""])( + "resolves as a draw when winner is %p", + async (winner) => { + escrowService.resolveMatch.mockResolvedValue({ hash: "0xTX4", blockNumber: 45 }); + + const res = await request(app) + .post("/api/escrow/resolve") + .send({ gameCode: "ABC123", winner }); + + expect(res.status).toBe(200); + expect(escrowService.resolveMatch).toHaveBeenCalledWith( + "ABC123", + escrowService.DRAW_ADDRESS, + ); + }, + ); + + test("returns 400 when gameCode is missing", async () => { + const res = await request(app).post("/api/escrow/resolve").send({ winner: "PLAYER1" }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "gameCode required" }); + expect(escrowService.resolveMatch).not.toHaveBeenCalled(); + }); + + test("returns 500 when the on-chain resolve fails", async () => { + escrowService.resolveMatch.mockRejectedValue(new Error("already resolved")); + + const res = await request(app) + .post("/api/escrow/resolve") + .send({ gameCode: "ABC123", winner: "PLAYER1" }); + + expect(res.status).toBe(500); + }); + }); +}); diff --git a/backend/tests/socket.test.js b/backend/tests/socket.test.js new file mode 100644 index 0000000..24a29f9 --- /dev/null +++ b/backend/tests/socket.test.js @@ -0,0 +1,422 @@ +/** + * Socket.io real-time game room tests: concurrent client connections, + * move/presence broadcast correctness, and room isolation. + * + * These tests boot the *real* server (server.js) on an ephemeral port and + * drive it with real socket.io-client connections, so the actual + * `io.on("connection", ...)` wiring in server.js is exercised end to end. + * Supabase, the escrow/cron services, and game/timer persistence are + * mocked so no external network calls are made. + */ + +process.env.NODE_ENV = "test"; +process.env.SUPABASE_URL = "http://localhost"; +process.env.SUPABASE_KEY = "test-key"; +process.env.SOROBAN_RPC_URL = "http://localhost"; +process.env.PORT = "0"; + +const Client = require("socket.io-client"); +const request = require("supertest"); + +// @stellar/stellar-sdk transitively ships an ESM-only dependency +// (@noble/hashes) that Jest's CJS transform can't parse. It's required +// directly by healthRoutes.js and authService.js (neither of which is +// exercised by these socket tests), so stub the whole package. +jest.mock("@stellar/stellar-sdk", () => ({ + Keypair: { fromSecret: jest.fn(), random: jest.fn() }, + rpc: { Server: jest.fn().mockImplementation(() => ({})) }, + TransactionBuilder: jest.fn(), + Networks: { TESTNET: "Test SDF Network ; September 2015", PUBLIC: "Public Global Stellar Network ; September 2015" }, + Contract: jest.fn(), + xdr: {}, + scValToNative: jest.fn(), + nativeToScVal: jest.fn(), +})); + +const mockChatInsertResult = { data: null, error: null }; + +jest.mock("../config/supabase", () => ({ + from: jest.fn(() => ({ + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn(() => Promise.resolve(mockChatInsertResult)), + })), + })), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + lt: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue({ data: [], error: null }), + })), +})); + +jest.mock("../services/cronService", () => ({ + start: jest.fn(), + stop: jest.fn(), +})); + +// authRoutes -> authController -> authService pulls in @stellar/stellar-sdk, +// which transitively ships an ESM-only dependency (@noble/hashes) that Jest's +// CJS transform can't parse. Mocked purely to keep server.js requirable; +// socket behavior under test doesn't touch auth. +jest.mock("../services/authService", () => ({ + createChallenge: jest.fn(), + verifyChallenge: jest.fn(), + generateToken: jest.fn(), + verifyToken: jest.fn(), +})); + +jest.mock("../services/escrowService", () => ({ + init: jest.fn(), + getMatch: jest.fn(), + createMatch: jest.fn(), + joinMatch: jest.fn(), + resolveMatch: jest.fn(), + DRAW_ADDRESS: "DRAW_ADDRESS_PLACEHOLDER", +})); + +// Real timerService.startReconnectGrace schedules a real 60s setTimeout, +// which would otherwise keep the Jest process alive after the suite ends. +// The disconnect/reconnect socket handlers only call through these methods; +// their internal timing isn't what this suite is testing. +jest.mock("../services/timerService", () => ({ + init: jest.fn(), + isPendingForfeit: jest.fn().mockReturnValue(false), + cancelReconnectGrace: jest.fn(), + startReconnectGrace: jest.fn(), + startClock: jest.fn(), + getClockState: jest.fn(), + applyMove: jest.fn(), + clearTimer: jest.fn(), + clearClock: jest.fn(), +})); + +jest.mock("../models/gameModel", () => ({ + createGame: jest.fn(), + joinGame: jest.fn(), + getGame: jest.fn().mockResolvedValue({ current_turn: "white" }), + getPendingGames: jest.fn(), + makeMove: jest.fn(), + getMoves: jest.fn(), + resignGame: jest.fn(), + offerDraw: jest.fn(), + acceptDraw: jest.fn(), + getChatMessages: jest.fn(), + getGameHistory: jest.fn(), + requestUndoMove: jest.fn(), + acceptUndoMove: jest.fn(), + rejectUndoMove: jest.fn(), +})); + +let server; +let io; +let app; +let port; + +function connectClient() { + return Client(`http://localhost:${port}`, { + transports: ["websocket"], + forceNew: true, + }); +} + +function waitForEvent(socket, event) { + return new Promise((resolve) => socket.once(event, resolve)); +} + +function waitForConnect(socket) { + return new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("connect_error", reject); + }); +} + +beforeAll(async () => { + ({ app, server, io } = require("../server")); + if (!server.listening) { + await new Promise((resolve) => server.once("listening", resolve)); + } + port = server.address().port; +}); + +afterAll(async () => { + if (!io || !server) return; + io.close(); + await new Promise((resolve) => server.close(resolve)); +}); + +describe("Socket.io real-time game rooms", () => { + let clients = []; + + afterEach(() => { + clients.forEach((c) => c.disconnect()); + clients = []; + }); + + test("a client can join a game room and receives a presence snapshot", async () => { + const client = connectClient(); + clients.push(client); + await waitForConnect(client); + + const snapshotPromise = waitForEvent(client, "presence-snapshot"); + client.emit("join-game", { gameCode: "ROOM_A", playerColor: "white" }); + + const snapshot = await snapshotPromise; + expect(snapshot).toEqual({ + gameCode: "ROOM_A", + white: "online", + black: "offline", + }); + }); + + test("accepts a bare gameCode string as a spectator join", async () => { + const client = connectClient(); + clients.push(client); + await waitForConnect(client); + + const snapshotPromise = waitForEvent(client, "presence-snapshot"); + client.emit("join-game", "ROOM_SPECTATE"); + + const snapshot = await snapshotPromise; + expect(snapshot.gameCode).toBe("ROOM_SPECTATE"); + }); + + test("joining as a player broadcasts presence-update to everyone in the room", async () => { + const spectator = connectClient(); + const player = connectClient(); + clients.push(spectator, player); + await Promise.all([waitForConnect(spectator), waitForConnect(player)]); + + spectator.emit("join-game", "ROOM_B"); + await waitForEvent(spectator, "presence-snapshot"); + + const presenceUpdatePromise = waitForEvent(spectator, "presence-update"); + player.emit("join-game", { gameCode: "ROOM_B", playerColor: "black" }); + + const update = await presenceUpdatePromise; + expect(update).toEqual({ gameCode: "ROOM_B", color: "black", status: "online" }); + }); + + test("room isolation: a client in a different room does not receive another room's presence-update", async () => { + const inRoomA = connectClient(); + const inRoomC = connectClient(); + const joiner = connectClient(); + clients.push(inRoomA, inRoomC, joiner); + await Promise.all([waitForConnect(inRoomA), waitForConnect(inRoomC), waitForConnect(joiner)]); + + inRoomA.emit("join-game", "ROOM_ISOLATION_A"); + inRoomC.emit("join-game", "ROOM_ISOLATION_C"); + await Promise.all([ + waitForEvent(inRoomA, "presence-snapshot"), + waitForEvent(inRoomC, "presence-snapshot"), + ]); + + const leakedEvent = jest.fn(); + inRoomC.on("presence-update", leakedEvent); + + const presenceUpdatePromise = waitForEvent(inRoomA, "presence-update"); + joiner.emit("join-game", { gameCode: "ROOM_ISOLATION_A", playerColor: "white" }); + await presenceUpdatePromise; + + // Give any wrongly-broadcast event a moment to arrive before asserting. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(leakedEvent).not.toHaveBeenCalled(); + }); + + test("leave-game removes the socket from the room so it stops receiving broadcasts", async () => { + const leaver = connectClient(); + const stayer = connectClient(); + clients.push(leaver, stayer); + await Promise.all([waitForConnect(leaver), waitForConnect(stayer)]); + + leaver.emit("join-game", "ROOM_LEAVE"); + await waitForEvent(leaver, "presence-snapshot"); + leaver.emit("leave-game", "ROOM_LEAVE"); + + const leakedEvent = jest.fn(); + leaver.on("presence-update", leakedEvent); + + stayer.emit("join-game", { gameCode: "ROOM_LEAVE", playerColor: "black" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(leakedEvent).not.toHaveBeenCalled(); + }); + + test("concurrent joins across many clients each get their own presence snapshot and correct room membership", async () => { + const roomAClients = Array.from({ length: 5 }, connectClient); + const roomBClients = Array.from({ length: 5 }, connectClient); + clients.push(...roomAClients, ...roomBClients); + await Promise.all([...roomAClients, ...roomBClients].map(waitForConnect)); + + const roomAReceived = roomAClients.map((c) => jest.fn()); + const roomBReceived = roomBClients.map((c) => jest.fn()); + roomAClients.forEach((c, idx) => c.on("presence-update", roomAReceived[idx])); + roomBClients.forEach((c, idx) => c.on("presence-update", roomBReceived[idx])); + + // All clients join concurrently, half in room A, half in room B. + await Promise.all([ + ...roomAClients.map((c) => { + const p = waitForEvent(c, "presence-snapshot"); + c.emit("join-game", "ROOM_CONCURRENT_A"); + return p; + }), + ...roomBClients.map((c) => { + const p = waitForEvent(c, "presence-snapshot"); + c.emit("join-game", "ROOM_CONCURRENT_B"); + return p; + }), + ]); + + // A fresh player join to room A should broadcast to every room-A client + // (including spectators) and to zero room-B clients. + const playerA = connectClient(); + clients.push(playerA); + await waitForConnect(playerA); + + const roomAUpdatePromises = roomAClients.map((c) => waitForEvent(c, "presence-update")); + playerA.emit("join-game", { gameCode: "ROOM_CONCURRENT_A", playerColor: "white" }); + await Promise.all(roomAUpdatePromises); + + await new Promise((resolve) => setTimeout(resolve, 50)); + roomBReceived.forEach((fn) => expect(fn).not.toHaveBeenCalled()); + }); + + test("send-chat persists and broadcasts chat-message only within the room", async () => { + mockChatInsertResult.data = { + id: 1, + player_color: "white", + message: "gg", + created_at: "2024-01-01T00:00:00.000Z", + }; + mockChatInsertResult.error = null; + + const inRoom = connectClient(); + const outsideRoom = connectClient(); + clients.push(inRoom, outsideRoom); + await Promise.all([waitForConnect(inRoom), waitForConnect(outsideRoom)]); + + inRoom.emit("join-game", "ROOM_CHAT"); + outsideRoom.emit("join-game", "ROOM_CHAT_OTHER"); + await Promise.all([ + waitForEvent(inRoom, "presence-snapshot"), + waitForEvent(outsideRoom, "presence-snapshot"), + ]); + + const leakedChat = jest.fn(); + outsideRoom.on("chat-message", leakedChat); + + const chatPromise = waitForEvent(inRoom, "chat-message"); + inRoom.emit("send-chat", { gameCode: "ROOM_CHAT", playerColor: "white", message: "gg" }); + + const chatMessage = await chatPromise; + expect(chatMessage).toEqual({ + id: 1, + playerColor: "white", + message: "gg", + createdAt: "2024-01-01T00:00:00.000Z", + }); + expect(leakedChat).not.toHaveBeenCalled(); + }); + + test("send-chat sanitizes HTML and control characters before broadcasting", async () => { + mockChatInsertResult.data = { + id: 2, + player_color: "black", + message: "alert", + created_at: "2024-01-01T00:00:00.000Z", + }; + mockChatInsertResult.error = null; + + const client = connectClient(); + clients.push(client); + await waitForConnect(client); + + client.emit("join-game", "ROOM_SANITIZE"); + await waitForEvent(client, "presence-snapshot"); + + const chatPromise = waitForEvent(client, "chat-message"); + client.emit("send-chat", { + gameCode: "ROOM_SANITIZE", + playerColor: "black", + message: "", + }); + + const chatMessage = await chatPromise; + // The handler strips tags/angle-brackets before insert; we only assert + // the broadcast reflects whatever the (mocked) persisted row contains. + expect(chatMessage.message).toBe("alert"); + }); + + test("send-chat is a no-op when required fields are missing", async () => { + const client = connectClient(); + clients.push(client); + await waitForConnect(client); + + client.emit("join-game", "ROOM_INVALID_CHAT"); + await waitForEvent(client, "presence-snapshot"); + + const chatHandler = jest.fn(); + client.on("chat-message", chatHandler); + + client.emit("send-chat", { gameCode: "ROOM_INVALID_CHAT", playerColor: "white" }); // no message + client.emit("send-chat", { gameCode: "ROOM_INVALID_CHAT", playerColor: "purple", message: "hi" }); // bad color + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(chatHandler).not.toHaveBeenCalled(); + }); + + test("REST-triggered game-update (move) is broadcast in real time only to sockets in that game's room", async () => { + const gameModel = require("../models/gameModel"); + gameModel.getGame.mockResolvedValue({ current_turn: "white" }); + gameModel.makeMove.mockResolvedValue({ + game_code: "ROOM_MOVE", + status: "active", + current_turn: "black", + }); + + const inGameRoom = connectClient(); + const inOtherRoom = connectClient(); + clients.push(inGameRoom, inOtherRoom); + await Promise.all([waitForConnect(inGameRoom), waitForConnect(inOtherRoom)]); + + inGameRoom.emit("join-game", "ROOM_MOVE"); + inOtherRoom.emit("join-game", "ROOM_MOVE_OTHER"); + await Promise.all([ + waitForEvent(inGameRoom, "presence-snapshot"), + waitForEvent(inOtherRoom, "presence-snapshot"), + ]); + + const leakedUpdate = jest.fn(); + inOtherRoom.on("game-update", leakedUpdate); + + const updatePromise = waitForEvent(inGameRoom, "game-update"); + const res = await request(app) + .post("/api/games/ROOM_MOVE/move") + .send({ from: "e2", to: "e4" }); + expect(res.status).toBe(200); + + const update = await updatePromise; + expect(update.game_code).toBe("ROOM_MOVE"); + expect(update.current_turn).toBe("black"); + expect(leakedUpdate).not.toHaveBeenCalled(); + }); + + test("disconnect clears presence and starts a reconnect grace period", async () => { + const player = connectClient(); + const observer = connectClient(); + clients.push(player, observer); + await Promise.all([waitForConnect(player), waitForConnect(observer)]); + + observer.emit("join-game", "ROOM_DISCONNECT"); + await waitForEvent(observer, "presence-snapshot"); + + player.emit("join-game", { gameCode: "ROOM_DISCONNECT", playerColor: "white" }); + await waitForEvent(observer, "presence-update"); // "online" + + const reconnectingPromise = waitForEvent(observer, "presence-update"); + player.disconnect(); + clients = clients.filter((c) => c !== player); + + const update = await reconnectingPromise; + expect(update).toEqual({ gameCode: "ROOM_DISCONNECT", color: "white", status: "reconnecting" }); + }); +}); diff --git a/contracts/soroban/contracts/escrow/src/test.rs b/contracts/soroban/contracts/escrow/src/test.rs index cf9b53f..1da50b8 100644 --- a/contracts/soroban/contracts/escrow/src/test.rs +++ b/contracts/soroban/contracts/escrow/src/test.rs @@ -5,7 +5,7 @@ use soroban_sdk::token::Client as TokenClient; use soroban_sdk::token::StellarAssetClient as TokenAdminClient; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, - vec, Address, Env, String, + vec, Address, Env, IntoVal, String, }; fn create_token_contract<'a>(e: &Env, admin: &Address) -> (TokenClient<'a>, TokenAdminClient<'a>) { @@ -1063,67 +1063,12 @@ fn test_native_xlm_payment_wrapping() { assert_eq!(match_data.total_staked, 200); } -#[test] -fn test_cancel_pending_match_unjoined() { -fn test_set_and_get_treasury_vault() { -// --------------------------------------------------------------------------- -// Issue #21 — Match Expiration & Auto-Claim Refund Timeout Logic -// --------------------------------------------------------------------------- - -#[test] -fn test_set_and_get_match_timeout() { -// Issue #23 — Dynamic Wager Scaling with Configurable Minimum & Maximum Limits // --------------------------------------------------------------------------- - -#[test] -fn test_default_wager_limits() { -// On-Chain Player Elo Rating Ledger Proofs +// Issue #20 — Pending Match Cancellation // --------------------------------------------------------------------------- #[test] -fn test_default_elo_is_1200() { - let env = Env::default(); - env.mock_all_auths(); - - let coordinator = Address::generate(&env); - let contract_id = env.register(ChessterEscrow, ()); - let client = ChessterEscrowClient::new(&env, &contract_id); - - client.init(&coordinator, &500); - - // Defaults to MATCH_EXPIRATION_SECS (3600) - assert_eq!(client.get_match_timeout(), 3600); - - // Coordinator updates match timeout to 1800 (30 mins) - client.set_match_timeout(&1800); - assert_eq!(client.get_match_timeout(), 1800); -} - -#[test] -#[should_panic(expected = "HostError")] -fn test_set_match_timeout_rejects_zero() { - let limits = client.get_wager_limits(); - assert_eq!(limits.min_wager, 1); - assert_eq!(limits.max_wager, i128::MAX); - assert_eq!(client.get_min_wager(), 1); - assert_eq!(client.get_max_wager(), i128::MAX); -} - -#[test] -fn test_set_and_get_global_wager_limits() { - let env = Env::default(); - env.mock_all_auths(); - - let coordinator = Address::generate(&env); - let contract_id = env.register(ChessterEscrow, ()); - let client = ChessterEscrowClient::new(&env, &contract_id); - - client.init(&coordinator, &500); - client.set_match_timeout(&0); -} - -#[test] -fn test_is_match_expired_and_get_expiration() { +fn test_cancel_pending_match_unjoined() { let env = Env::default(); env.mock_all_auths(); @@ -1142,7 +1087,6 @@ fn test_is_match_expired_and_get_expiration() { let game_code = String::from_str(&env, "UNJOINED_CANCEL_GAME"); approve(&env, &token, &player1, &contract_id, 1000); - client.create_match(&game_code, &player1, &token.address, &200); assert_eq!(token.balance(&player1), 800); @@ -1155,45 +1099,13 @@ fn test_is_match_expired_and_get_expiration() { assert_eq!(match_data.status, MatchStatus::Refunded); // Player 1 deposit refunded in full - env.ledger().with_mut(|li| { - li.timestamp = 10_000; - }); - - let game_code = String::from_str(&env, "GAME_EXP_CHECK"); - approve(&env, &token, &player1, &contract_id, 1000); - client.create_match(&game_code, &player1, &token.address, &100); - - assert_eq!(client.get_match_expiration(&game_code), 13_600); - assert!(!client.is_match_expired(&game_code)); - - // Fast forward to exactly expiration - env.ledger().with_mut(|li| { - li.timestamp = 13_600; - }); - assert!(client.is_match_expired(&game_code)); - - // Set custom global limits - client.set_wager_limits(&100, &5000); - let limits = client.get_wager_limits(); - assert_eq!(limits.min_wager, 100); - assert_eq!(limits.max_wager, 5000); - assert_eq!(client.get_min_wager(), 100); - assert_eq!(client.get_max_wager(), 5000); - - // Set individual min and max - client.set_min_wager(&200); - assert_eq!(client.get_min_wager(), 200); - assert_eq!(client.get_max_wager(), 5000); - - client.set_max_wager(&8000); - assert_eq!(client.get_min_wager(), 200); - assert_eq!(client.get_max_wager(), 8000); + assert_eq!(token.balance(&player1), 1000); + assert_eq!(token.balance(&contract_id), 0); } #[test] #[should_panic(expected = "HostError")] -fn test_join_match_rejects_expired_match() { -fn test_set_wager_limits_zero_or_negative_min_fails() { +fn test_cancel_pending_match_rejects_after_join() { let env = Env::default(); env.mock_all_auths(); @@ -1212,43 +1124,29 @@ fn test_set_wager_limits_zero_or_negative_min_fails() { client.init(&coordinator, &500); client.add_whitelisted_token(&token.address); - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - - let game_code = String::from_str(&env, "GAME_EXPIRED_JOIN"); + let game_code = String::from_str(&env, "CANCEL_AFTER_JOIN"); approve(&env, &token, &player1, &contract_id, 1000); approve(&env, &token, &player2, &contract_id, 1000); client.create_match(&game_code, &player1, &token.address, &100); - - // Timestamp past timeout (1000 + 3600 = 4600) - env.ledger().with_mut(|li| { - li.timestamp = 4601; - }); - - // Player 2 attempts to join expired match -> fails client.join_match(&game_code, &player2); - client.set_wager_limits(&0, &1000); + + // Match is now Active; instant unjoined-cancel path must reject it. + client.cancel_pending_match(&game_code, &player1); } #[test] #[should_panic(expected = "HostError")] -fn test_place_side_bet_rejects_expired_match() { -fn test_set_wager_limits_max_smaller_than_min_fails() { +fn test_cancel_pending_match_rejects_non_creator() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); - let treasury_vault = Address::generate(&env); let player1 = Address::generate(&env); - let player2 = Address::generate(&env); - let spectator = Address::generate(&env); + let outsider = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token_contract(&env, &token_admin); token_admin_client.mint(&player1, &1000); - token_admin_client.mint(&player2, &1000); - token_admin_client.mint(&spectator, &500); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); @@ -1256,36 +1154,24 @@ fn test_set_wager_limits_max_smaller_than_min_fails() { client.init(&coordinator, &500); client.add_whitelisted_token(&token.address); - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - - let game_code = String::from_str(&env, "GAME_EXPIRED_BET"); + let game_code = String::from_str(&env, "CANCEL_WRONG_CALLER"); approve(&env, &token, &player1, &contract_id, 1000); - approve(&env, &token, &player2, &contract_id, 1000); - approve(&env, &token, &spectator, &contract_id, 500); client.create_match(&game_code, &player1, &token.address, &100); - client.join_match(&game_code, &player2); - - // Expire match - env.ledger().with_mut(|li| { - li.timestamp = 4601; - }); - client.place_side_bet(&game_code, &spectator, &player1, &100); + client.cancel_pending_match(&game_code, &outsider); } +// --------------------------------------------------------------------------- +// Issue #19 — Treasury Vault & Fee Tiering +// --------------------------------------------------------------------------- + #[test] -fn test_claim_refund_by_player1_pending() { +fn test_set_and_get_treasury_vault() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); - let player1 = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token_contract(&env, &token_admin); - token_admin_client.mint(&player1, &1000); + let treasury_vault = Address::generate(&env); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); @@ -1300,41 +1186,6 @@ fn test_claim_refund_by_player1_pending() { #[test] fn test_resolve_match_sends_fee_to_treasury_vault() { - client.add_whitelisted_token(&token.address); - - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - - let game_code = String::from_str(&env, "CLAIM_PENDING"); - approve(&env, &token, &player1, &contract_id, 1000); - client.create_match(&game_code, &player1, &token.address, &100); - - assert_eq!(token.balance(&player1), 900); - assert_eq!(token.balance(&contract_id), 100); - - // Advance past timeout - env.ledger().with_mut(|li| { - li.timestamp = 4601; - }); - - // Player 1 claims refund - client.claim_refund(&game_code, &player1); - - let match_data = client.get_match(&game_code); - assert_eq!(match_data.status, MatchStatus::Refunded); - assert_eq!(token.balance(&player1), 1000); - assert_eq!(token.balance(&contract_id), 0); -} - -#[test] -fn test_cooperative_mutual_draw_resolution() { -fn test_claim_refund_by_player2_active() { - client.set_wager_limits(&1000, &500); -} - -#[test] -fn test_create_match_enforces_wager_limits() { let env = Env::default(); env.mock_all_auths(); @@ -1373,117 +1224,91 @@ fn test_create_match_enforces_wager_limits() { #[test] fn test_set_fee_bps_and_wager_fee_tiering() { - client.init(&coordinator, &500); - client.add_whitelisted_token(&token.address); - - let game_code = String::from_str(&env, "MUTUAL_DRAW_GAME"); - approve(&env, &token, &player1, &contract_id, 1000); - approve(&env, &token, &player2, &contract_id, 1000); - - client.create_match(&game_code, &player1, &token.address, &300); - client.join_match(&game_code, &player2); - - assert_eq!(client.get_draw_status(&game_code), (false, false)); - - // Player 1 requests draw - client.request_draw(&game_code, &player1); - assert_eq!(client.get_draw_status(&game_code), (true, false)); - - let match_data_pending = client.get_match(&game_code); - assert_eq!(match_data_pending.status, MatchStatus::Active); - - // Player 2 requests draw -> triggers 50/50 refund draw resolution - client.request_draw(&game_code, &player2); - assert_eq!(client.get_draw_status(&game_code), (true, true)); - - let match_data_resolved = client.get_match(&game_code); - assert_eq!(match_data_resolved.status, MatchStatus::Resolved); - assert_eq!(match_data_resolved.winner, None); + let env = Env::default(); + env.mock_all_auths(); - // 100% refund of wagers to both players (300 to P1, 300 to P2) - assert_eq!(token.balance(&player1), 1000); - assert_eq!(token.balance(&player2), 1000); - assert_eq!(token.balance(&contract_id), 0); - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); - let game_code = String::from_str(&env, "CLAIM_ACTIVE"); - approve(&env, &token, &player1, &contract_id, 1000); - approve(&env, &token, &player2, &contract_id, 1000); - client.create_match(&game_code, &player1, &token.address, &100); - client.join_match(&game_code, &player2); + client.init(&coordinator, &500); // Default 500 bps (5%) + assert_eq!(client.get_fee_bps(), 500); - assert_eq!(token.balance(&player1), 900); - assert_eq!(token.balance(&player2), 900); - assert_eq!(token.balance(&contract_id), 200); + client.set_fee_bps(&1000); // Update base fee to 1000 bps (10%) + assert_eq!(client.get_fee_bps(), 1000); - // Advance past timeout - env.ledger().with_mut(|li| { - li.timestamp = 4601; - }); + // Standard wager (< 1000): 100% of base fee -> 1000 bps + assert_eq!(client.get_wager_tier_fee_bps(&500), 1000); - // Player 2 claims refund - client.claim_refund(&game_code, &player2); + // Mid wager (>= 1000): 15% discount -> 850 bps + assert_eq!(client.get_wager_tier_fee_bps(&1000), 850); - let match_data = client.get_match(&game_code); - assert_eq!(match_data.status, MatchStatus::Refunded); - assert_eq!(token.balance(&player1), 1000); - assert_eq!(token.balance(&player2), 1000); - assert_eq!(token.balance(&contract_id), 0); + // High wager (>= 10000): 30% discount -> 700 bps + assert_eq!(client.get_wager_tier_fee_bps(&10000), 700); } #[test] #[should_panic(expected = "HostError")] -fn test_claim_refund_before_timeout_fails() { +fn test_set_fee_bps_rejects_over_10000() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); - let player1 = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token_contract(&env, &token_admin); - token_admin_client.mint(&player1, &1000); - let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); client.init(&coordinator, &500); - client.add_whitelisted_token(&token.address); + client.set_fee_bps(&10001); +} - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); +// --------------------------------------------------------------------------- +// Issue #21 — Match Expiration & Auto-Claim Refund Timeout Logic +// --------------------------------------------------------------------------- - let game_code = String::from_str(&env, "CLAIM_EARLY"); - approve(&env, &token, &player1, &contract_id, 1000); - client.create_match(&game_code, &player1, &token.address, &100); +#[test] +fn test_set_and_get_match_timeout() { + let env = Env::default(); + env.mock_all_auths(); - // Not yet timed out - env.ledger().with_mut(|li| { - li.timestamp = 2000; - }); + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); - client.claim_refund(&game_code, &player1); + client.init(&coordinator, &500); + + // Defaults to MATCH_EXPIRATION_SECS (3600) + assert_eq!(client.get_match_timeout(), 3600); + + // Coordinator updates match timeout to 1800 (30 mins) + client.set_match_timeout(&1800); + assert_eq!(client.get_match_timeout(), 1800); } #[test] #[should_panic(expected = "HostError")] -fn test_claim_refund_unauthorized_fails() { +fn test_set_match_timeout_rejects_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_match_timeout(&0); +} + +#[test] +fn test_is_match_expired_and_get_expiration() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); let player1 = Address::generate(&env); - let outsider = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token_contract(&env, &token_admin); token_admin_client.mint(&player1, &1000); - token_admin_client.mint(&player1, &10000); - token_admin_client.mint(&player2, &10000); - let player = Address::generate(&env); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); @@ -1492,70 +1317,114 @@ fn test_claim_refund_unauthorized_fails() { client.add_whitelisted_token(&token.address); env.ledger().with_mut(|li| { - li.timestamp = 1000; + li.timestamp = 10_000; }); - let game_code = String::from_str(&env, "CLAIM_UNAUTH"); + let game_code = String::from_str(&env, "GAME_EXP_CHECK"); approve(&env, &token, &player1, &contract_id, 1000); client.create_match(&game_code, &player1, &token.address, &100); + assert_eq!(client.get_match_expiration(&game_code), 13_600); + assert!(!client.is_match_expired(&game_code)); + + // Fast forward to exactly expiration env.ledger().with_mut(|li| { - li.timestamp = 4601; + li.timestamp = 13_600; }); - - // Outsider tries to claim refund -> fails with Unauthorized - client.claim_refund(&game_code, &outsider); + assert!(client.is_match_expired(&game_code)); } #[test] -fn test_auto_claim_refund_by_coordinator() { - client.set_wager_limits(&100, &1000); - - approve(&env, &token, &player1, &contract_id, 10000); - approve(&env, &token, &player2, &contract_id, 10000); +#[should_panic(expected = "HostError")] +fn test_join_match_rejects_nonexistent_match() { + let env = Env::default(); + env.mock_all_auths(); - // Exact min wager: 100 succeeds - let g1 = String::from_str(&env, "GAME_MIN"); - client.create_match(&g1, &player1, &token.address, &100); - assert_eq!(client.get_match(&g1).wager_amount, 100); + let coordinator = Address::generate(&env); + let player2 = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); - // Mid-range wager: 500 succeeds - let g2 = String::from_str(&env, "GAME_MID"); - client.create_match(&g2, &player1, &token.address, &500); - assert_eq!(client.get_match(&g2).wager_amount, 500); + client.init(&coordinator, &500); - // Exact max wager: 1000 succeeds - let g3 = String::from_str(&env, "GAME_MAX"); - client.create_match(&g3, &player1, &token.address, &1000); - assert_eq!(client.get_match(&g3).wager_amount, 1000); + let game_code = String::from_str(&env, "NO_SUCH_GAME"); + client.join_match(&game_code, &player2); } #[test] #[should_panic(expected = "HostError")] -fn test_create_match_wager_below_minimum_fails() { +fn test_join_match_rejects_own_match() { + let env = Env::default(); + env.mock_all_auths(); - let default_elo = client.get_player_elo(&player); - assert_eq!(default_elo, 1200); + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "GAME_SELF_JOIN"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + client.join_match(&game_code, &player1); } #[test] -fn test_update_player_elo() { +#[should_panic(expected = "HostError")] +fn test_join_match_rejects_already_active_match() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); let player1 = Address::generate(&env); let player2 = Address::generate(&env); + let player3 = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token_contract(&env, &token_admin); token_admin_client.mint(&player1, &1000); token_admin_client.mint(&player2, &1000); + token_admin_client.mint(&player3, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "GAME_DOUBLE_JOIN"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + approve(&env, &token, &player3, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + // Match is already Active; a third player cannot join. + client.join_match(&game_code, &player3); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_join_match_rejects_expired_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token_contract(&env, &token_admin); - token_admin_client.mint(&player1, &10000); - let player = Address::generate(&env); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); @@ -1567,61 +1436,36 @@ fn test_update_player_elo() { li.timestamp = 1000; }); - let game_code = String::from_str(&env, "AUTO_CLAIM_COORD"); + let game_code = String::from_str(&env, "GAME_EXPIRED_JOIN"); approve(&env, &token, &player1, &contract_id, 1000); approve(&env, &token, &player2, &contract_id, 1000); client.create_match(&game_code, &player1, &token.address, &100); - client.join_match(&game_code, &player2); + // Timestamp past timeout (1000 + 3600 = 4600) env.ledger().with_mut(|li| { li.timestamp = 4601; }); - // Coordinator triggers auto_claim_refund - client.auto_claim_refund(&game_code, &coordinator); - - let match_data = client.get_match(&game_code); - assert_eq!(match_data.status, MatchStatus::Refunded); - assert_eq!(token.balance(&player1), 1000); - assert_eq!(token.balance(&player2), 1000); - assert_eq!(token.balance(&contract_id), 0); - client.set_wager_limits(&100, &1000); - - approve(&env, &token, &player1, &contract_id, 10000); - - let game_code = String::from_str(&env, "GAME_UNDER_MIN"); - client.create_match(&game_code, &player1, &token.address, &99); - - client.update_player_elo(&player, &1500); - - let updated_elo = client.get_player_elo(&player); - assert_eq!(updated_elo, 1500); + // Player 2 attempts to join expired match -> fails + client.join_match(&game_code, &player2); } #[test] #[should_panic(expected = "HostError")] -fn test_auto_claim_refund_unauthorized_fails() { -fn test_create_match_wager_above_maximum_fails() { +fn test_place_side_bet_rejects_expired_match() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); let player1 = Address::generate(&env); - let outsider = Address::generate(&env); + let player2 = Address::generate(&env); + let spectator = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token_contract(&env, &token_admin); token_admin_client.mint(&player1, &1000); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token_contract(&env, &token_admin); - token_admin_client.mint(&player1, &10000); -fn test_update_player_elo_unauthorized() { - let env = Env::default(); - - let coordinator = Address::generate(&env); - let player = Address::generate(&env); - let unauthorized_user = Address::generate(&env); + token_admin_client.mint(&player2, &1000); + token_admin_client.mint(&spectator, &500); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); @@ -1633,64 +1477,72 @@ fn test_update_player_elo_unauthorized() { li.timestamp = 1000; }); - let game_code = String::from_str(&env, "AUTO_UNAUTH"); + let game_code = String::from_str(&env, "GAME_EXPIRED_BET"); approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + approve(&env, &token, &spectator, &contract_id, 500); client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + // Expire match env.ledger().with_mut(|li| { li.timestamp = 4601; }); - client.auto_claim_refund(&game_code, &outsider); -} - -#[test] -fn test_auto_claim_expired_matches_batch() { - client.set_wager_limits(&100, &1000); - - approve(&env, &token, &player1, &contract_id, 10000); - - let game_code = String::from_str(&env, "GAME_OVER_MAX"); - client.create_match(&game_code, &player1, &token.address, &1001); + client.place_side_bet(&game_code, &spectator, &player1, &100); } #[test] -fn test_set_and_get_token_wager_limits() { +#[should_panic(expected = "HostError")] +fn test_place_side_bet_rejects_invalid_predicted_winner() { let env = Env::default(); env.mock_all_auths(); let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let outsider = Address::generate(&env); + let spectator = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + token_admin_client.mint(&spectator, &500); + let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); - client.init(&coordinator, &500); // Default 500 bps (5%) - assert_eq!(client.get_fee_bps(), 500); + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); - client.set_fee_bps(&1000); // Update base fee to 1000 bps (10%) - assert_eq!(client.get_fee_bps(), 1000); + let game_code = String::from_str(&env, "GAME_BAD_PREDICTION"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + approve(&env, &token, &spectator, &contract_id, 500); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); - // Standard wager (< 1000): 100% of base fee -> 1000 bps - assert_eq!(client.get_wager_tier_fee_bps(&500), 1000); + // outsider is not a participant in this match. + client.place_side_bet(&game_code, &spectator, &outsider, &100); +} - // Mid wager (>= 1000): 15% discount -> 850 bps - assert_eq!(client.get_wager_tier_fee_bps(&1000), 850); +#[test] +#[should_panic(expected = "HostError")] +fn test_place_side_bet_rejects_zero_amount() { + let env = Env::default(); + env.mock_all_auths(); - // High wager (>= 10000): 30% discount -> 700 bps - assert_eq!(client.get_wager_tier_fee_bps(&10000), 700); + let coordinator = Address::generate(&env); let player1 = Address::generate(&env); let player2 = Address::generate(&env); + let spectator = Address::generate(&env); let token_admin = Address::generate(&env); let (token, token_admin_client) = create_token_contract(&env, &token_admin); token_admin_client.mint(&player1, &1000); token_admin_client.mint(&player2, &1000); - let token_admin = Address::generate(&env); - - let (token1, token1_admin_client) = create_token_contract(&env, &token_admin); - let (token2, token2_admin_client) = create_token_contract(&env, &token_admin); - - token1_admin_client.mint(&player1, &10000); - token2_admin_client.mint(&player1, &10000); + token_admin_client.mint(&spectator, &500); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); @@ -1698,39 +1550,240 @@ fn test_set_and_get_token_wager_limits() { client.init(&coordinator, &500); client.add_whitelisted_token(&token.address); - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - - let g1 = String::from_str(&env, "BATCH_EXP_1"); - let g2 = String::from_str(&env, "BATCH_EXP_2"); - let g3 = String::from_str(&env, "BATCH_EXP_3"); - + let game_code = String::from_str(&env, "GAME_ZERO_BET"); approve(&env, &token, &player1, &contract_id, 1000); approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); - client.create_match(&g1, &player1, &token.address, &100); - client.create_match(&g2, &player1, &token.address, &100); + client.place_side_bet(&game_code, &spectator, &player1, &0); +} - // Advance time so g1 and g2 expire - env.ledger().with_mut(|li| { - li.timestamp = 4601; - }); +// --------------------------------------------------------------------------- +// Issue #23 — Dynamic Wager Scaling with Configurable Minimum & Maximum Limits +// --------------------------------------------------------------------------- - // Create g3 at 4601 (not expired) - client.create_match(&g3, &player1, &token.address, &100); +#[test] +fn test_default_wager_limits() { + let env = Env::default(); + env.mock_all_auths(); - let codes = vec![&env, g1.clone(), g2.clone(), g3.clone()]; - let refunded_count = client.auto_claim_expired_matches(&codes); + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); - assert_eq!(refunded_count, 2); - assert_eq!(client.get_match(&g1).status, MatchStatus::Refunded); - assert_eq!(client.get_match(&g2).status, MatchStatus::Refunded); - assert_eq!(client.get_match(&g3).status, MatchStatus::Pending); + client.init(&coordinator, &500); + + let limits = client.get_wager_limits(); + assert_eq!(limits.min_wager, 1); + assert_eq!(limits.max_wager, i128::MAX); + assert_eq!(client.get_min_wager(), 1); + assert_eq!(client.get_max_wager(), i128::MAX); } #[test] -fn test_expired_event_emitted_on_claim_refund() { +fn test_set_and_get_global_wager_limits() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + + // Set custom global limits + client.set_wager_limits(&100, &5000); + let limits = client.get_wager_limits(); + assert_eq!(limits.min_wager, 100); + assert_eq!(limits.max_wager, 5000); + assert_eq!(client.get_min_wager(), 100); + assert_eq!(client.get_max_wager(), 5000); + + // Set individual min and max + client.set_min_wager(&200); + assert_eq!(client.get_min_wager(), 200); + assert_eq!(client.get_max_wager(), 5000); + + client.set_max_wager(&8000); + assert_eq!(client.get_min_wager(), 200); + assert_eq!(client.get_max_wager(), 8000); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_set_wager_limits_zero_min_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_wager_limits(&0, &1000); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_set_wager_limits_negative_min_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_wager_limits(&-100, &1000); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_set_wager_limits_max_smaller_than_min_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_wager_limits(&1000, &500); +} + +#[test] +fn test_create_match_enforces_wager_limits() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &10000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + client.set_wager_limits(&100, &1000); + + approve(&env, &token, &player1, &contract_id, 10000); + + // Exact min wager: 100 succeeds + let g1 = String::from_str(&env, "GAME_MIN"); + client.create_match(&g1, &player1, &token.address, &100); + assert_eq!(client.get_match(&g1).wager_amount, 100); + + // Mid-range wager: 500 succeeds + let g2 = String::from_str(&env, "GAME_MID"); + client.create_match(&g2, &player1, &token.address, &500); + assert_eq!(client.get_match(&g2).wager_amount, 500); + + // Exact max wager: 1000 succeeds + let g3 = String::from_str(&env, "GAME_MAX"); + client.create_match(&g3, &player1, &token.address, &1000); + assert_eq!(client.get_match(&g3).wager_amount, 1000); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_create_match_wager_below_minimum_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &10000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + client.set_wager_limits(&100, &1000); + + approve(&env, &token, &player1, &contract_id, 10000); + + let game_code = String::from_str(&env, "GAME_UNDER_MIN"); + client.create_match(&game_code, &player1, &token.address, &99); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_create_match_wager_above_maximum_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &10000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + client.set_wager_limits(&100, &1000); + + approve(&env, &token, &player1, &contract_id, 10000); + + let game_code = String::from_str(&env, "GAME_OVER_MAX"); + client.create_match(&game_code, &player1, &token.address, &1001); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_create_match_zero_wager_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "GAME_ZERO_WAGER"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &0); +} + +#[test] +fn test_set_and_get_token_wager_limits() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token1, token1_admin_client) = create_token_contract(&env, &token_admin); + let (token2, token2_admin_client) = create_token_contract(&env, &token_admin); + + token1_admin_client.mint(&player1, &10000); + token2_admin_client.mint(&player1, &10000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); client.add_whitelisted_token(&token1.address); client.add_whitelisted_token(&token2.address); @@ -1740,74 +1793,1308 @@ fn test_expired_event_emitted_on_claim_refund() { // Token1 specific limits: 50 to 500 client.set_token_wager_limits(&token1.address, &50, &500); - assert_eq!(client.get_token_min_wager(&token1.address), 50); - assert_eq!(client.get_token_max_wager(&token1.address), 500); + assert_eq!(client.get_token_min_wager(&token1.address), 50); + assert_eq!(client.get_token_max_wager(&token1.address), 500); + + // Token2 has no specific limits, falls back to global (100 to 1000) + assert_eq!(client.get_token_min_wager(&token2.address), 100); + assert_eq!(client.get_token_max_wager(&token2.address), 1000); + + approve(&env, &token1, &player1, &contract_id, 10000); + approve(&env, &token2, &player1, &contract_id, 10000); + + // Token1: 75 succeeds (between 50 and 500) + let g1 = String::from_str(&env, "GAME_TOK1_OK"); + client.create_match(&g1, &player1, &token1.address, &75); + + // Token2: 75 fails because global min is 100 + let g2 = String::from_str(&env, "GAME_TOK2_FAIL"); + let res = client.try_create_match(&g2, &player1, &token2.address, &75); + assert!(res.is_err()); + + // Remove token1 limits -> token1 now falls back to global (100 to 1000) + client.remove_token_wager_limits(&token1.address); + assert_eq!(client.get_token_min_wager(&token1.address), 100); + assert_eq!(client.get_token_max_wager(&token1.address), 1000); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_set_token_wager_limits_invalid_range_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let token_admin = Address::generate(&env); + let (token, _admin_client) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_token_wager_limits(&token.address, &500, &100); +} + +#[test] +fn test_dynamic_wager_scaling() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let token_admin = Address::generate(&env); + let (token, _admin_client) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_wager_limits(&100, &1000); + + // Scale up by 1.5x (15,000 bps) + client.scale_wager_limits(&15_000); + assert_eq!(client.get_min_wager(), 150); + assert_eq!(client.get_max_wager(), 1500); + + // Scale down by 50% (5,000 bps) + client.scale_wager_limits(&5_000); + assert_eq!(client.get_min_wager(), 75); + assert_eq!(client.get_max_wager(), 750); + + // Dynamic scaling for token-specific limits + client.set_token_wager_limits(&token.address, &200, &2000); + client.scale_token_wager_limits(&token.address, &20_000); // 2x + assert_eq!(client.get_token_min_wager(&token.address), 400); + assert_eq!(client.get_token_max_wager(&token.address), 4000); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_scale_wager_limits_rejects_zero_scale() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_wager_limits(&100, &1000); + client.scale_wager_limits(&0); +} + +#[test] +fn test_scale_wager_limits_preserves_unbounded_max() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + // Default max is i128::MAX (DEFAULT_MAX_WAGER); scaling should keep it uncapped. + client.scale_wager_limits(&20_000); // 2x + assert_eq!(client.get_min_wager(), 2); + assert_eq!(client.get_max_wager(), i128::MAX); +} + +#[test] +fn test_native_match_enforces_wager_limits() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (native_token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &10000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.set_native_xlm_address(&native_token.address); + client.set_wager_limits(&100, &1000); + + approve(&env, &native_token, &player1, &contract_id, 10000); + + // Native match below min fails + let g1 = String::from_str(&env, "NATIVE_BELOW_MIN"); + let res = client.try_create_native_match(&g1, &player1, &50); + assert!(res.is_err()); + + // Native match within limits succeeds + let g2 = String::from_str(&env, "NATIVE_WITHIN_LIMITS"); + client.create_native_match(&g2, &player1, &500); + let match_data = client.get_match(&g2); + assert_eq!(match_data.wager_amount, 500); +} + +// --------------------------------------------------------------------------- +// Issue #21 — Claim / Auto-Claim Refund +// --------------------------------------------------------------------------- + +#[test] +fn test_claim_refund_by_player1_pending() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let game_code = String::from_str(&env, "CLAIM_PENDING"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + assert_eq!(token.balance(&player1), 900); + assert_eq!(token.balance(&contract_id), 100); + + // Advance past timeout + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + // Player 1 claims refund + client.claim_refund(&game_code, &player1); + + let match_data = client.get_match(&game_code); + assert_eq!(match_data.status, MatchStatus::Refunded); + assert_eq!(token.balance(&player1), 1000); + assert_eq!(token.balance(&contract_id), 0); +} + +#[test] +fn test_claim_refund_by_player2_active() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let game_code = String::from_str(&env, "CLAIM_ACTIVE"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + assert_eq!(token.balance(&player1), 900); + assert_eq!(token.balance(&player2), 900); + assert_eq!(token.balance(&contract_id), 200); + + // Advance past timeout + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + // Player 2 claims refund + client.claim_refund(&game_code, &player2); + + let match_data = client.get_match(&game_code); + assert_eq!(match_data.status, MatchStatus::Refunded); + assert_eq!(token.balance(&player1), 1000); + assert_eq!(token.balance(&player2), 1000); + assert_eq!(token.balance(&contract_id), 0); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_claim_refund_before_timeout_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let game_code = String::from_str(&env, "CLAIM_EARLY"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + // Not yet timed out + env.ledger().with_mut(|li| { + li.timestamp = 2000; + }); + + client.claim_refund(&game_code, &player1); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_claim_refund_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let outsider = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let game_code = String::from_str(&env, "CLAIM_UNAUTH"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + // Outsider tries to claim refund -> fails with Unauthorized + client.claim_refund(&game_code, &outsider); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_claim_refund_after_resolved_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "CLAIM_AFTER_RESOLVED"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + client.resolve_match(&game_code, &Some(player1.clone())); + + // Advance far past any timeout — match is already Resolved, not refundable. + env.ledger().with_mut(|li| { + li.timestamp = 100_000; + }); + client.claim_refund(&game_code, &player1); +} + +#[test] +fn test_auto_claim_refund_by_coordinator() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let game_code = String::from_str(&env, "AUTO_CLAIM_COORD"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + // Coordinator triggers auto_claim_refund + client.auto_claim_refund(&game_code, &coordinator); + + let match_data = client.get_match(&game_code); + assert_eq!(match_data.status, MatchStatus::Refunded); + assert_eq!(token.balance(&player1), 1000); + assert_eq!(token.balance(&player2), 1000); + assert_eq!(token.balance(&contract_id), 0); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_auto_claim_refund_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let outsider = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let game_code = String::from_str(&env, "AUTO_UNAUTH"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + client.auto_claim_refund(&game_code, &outsider); +} + +#[test] +fn test_auto_claim_expired_matches_batch() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let g1 = String::from_str(&env, "BATCH_EXP_1"); + let g2 = String::from_str(&env, "BATCH_EXP_2"); + let g3 = String::from_str(&env, "BATCH_EXP_3"); + + approve(&env, &token, &player1, &contract_id, 1000); + + client.create_match(&g1, &player1, &token.address, &100); + client.create_match(&g2, &player1, &token.address, &100); + + // Advance time so g1 and g2 expire + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + // Create g3 at 4601 (not expired) + client.create_match(&g3, &player1, &token.address, &100); + + let codes = vec![&env, g1.clone(), g2.clone(), g3.clone()]; + let refunded_count = client.auto_claim_expired_matches(&codes); + + assert_eq!(refunded_count, 2); + assert_eq!(client.get_match(&g1).status, MatchStatus::Refunded); + assert_eq!(client.get_match(&g2).status, MatchStatus::Refunded); + assert_eq!(client.get_match(&g3).status, MatchStatus::Pending); +} + +#[test] +fn test_expired_event_emitted_on_claim_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "GAME_EVENT_CLAIM_REFUND"); + approve(&env, &token, &player1, &contract_id, 1000); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + client.create_match(&game_code, &player1, &token.address, &100); + + env.ledger().with_mut(|li| { + li.timestamp = 4601; + }); + + client.claim_refund(&game_code, &player1); + + let events = env.events().all(); + assert!(events.iter().any(|(id, _, _)| id == contract_id)); +} + +// --------------------------------------------------------------------------- +// Issue #20 — Cooperative Mutual Draw +// --------------------------------------------------------------------------- + +#[test] +fn test_cooperative_mutual_draw_resolution() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "MUTUAL_DRAW_GAME"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + + client.create_match(&game_code, &player1, &token.address, &300); + client.join_match(&game_code, &player2); + + assert_eq!(client.get_draw_status(&game_code), (false, false)); + + // Player 1 requests draw + client.request_draw(&game_code, &player1); + assert_eq!(client.get_draw_status(&game_code), (true, false)); + + let match_data_pending = client.get_match(&game_code); + assert_eq!(match_data_pending.status, MatchStatus::Active); + + // Player 2 requests draw -> triggers 50/50 refund draw resolution + client.request_draw(&game_code, &player2); + assert_eq!(client.get_draw_status(&game_code), (true, true)); + + let match_data_resolved = client.get_match(&game_code); + assert_eq!(match_data_resolved.status, MatchStatus::Resolved); + assert_eq!(match_data_resolved.winner, None); + + // 100% refund of wagers to both players (300 to P1, 300 to P2) + assert_eq!(token.balance(&player1), 1000); + assert_eq!(token.balance(&player2), 1000); + assert_eq!(token.balance(&contract_id), 0); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_request_draw_rejects_pending_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DRAW_ON_PENDING"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + client.request_draw(&game_code, &player1); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_request_draw_rejects_non_participant() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let outsider = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DRAW_NON_PARTICIPANT"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.request_draw(&game_code, &outsider); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_request_cancellation_rejects_non_participant() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let outsider = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "CANCEL_NON_PARTICIPANT"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.request_cancellation(&game_code, &outsider); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_request_cancellation_rejects_resolved_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "CANCEL_RESOLVED"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + client.resolve_match(&game_code, &Some(player1.clone())); + + client.request_cancellation(&game_code, &player1); +} + +// --------------------------------------------------------------------------- +// Match resolution / forfeit edge cases +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "HostError")] +fn test_resolve_match_rejects_pending_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "RESOLVE_PENDING"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + client.resolve_match(&game_code, &Some(player1.clone())); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_resolve_match_rejects_double_resolution() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "RESOLVE_TWICE"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.resolve_match(&game_code, &Some(player1.clone())); + // Match is now Resolved; resolving again must fail. + client.resolve_match(&game_code, &Some(player2.clone())); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_resolve_match_rejects_invalid_winner() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let outsider = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "RESOLVE_BAD_WINNER"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + // outsider is not a participant of this match. + client.resolve_match(&game_code, &Some(outsider)); +} + +#[test] +fn test_resolve_match_allowed_while_paused() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "RESOLVE_WHILE_PAUSED"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + // Pause the contract — settlement should still be permitted. + client.pause(); + client.resolve_match(&game_code, &Some(player1.clone())); + + let match_data = client.get_match(&game_code); + assert_eq!(match_data.status, MatchStatus::Resolved); +} + +// --------------------------------------------------------------------------- +// Issue #27 — Dispute Handling & 48-Hour Timelock +// --------------------------------------------------------------------------- + +#[test] +fn test_raise_and_resolve_dispute_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| li.timestamp = 1000); + + let game_code = String::from_str(&env, "DISPUTE_GAME"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.raise_dispute(&game_code, &player1); + + let dispute = client.get_dispute(&game_code); + assert_eq!(dispute.status, DisputeStatus::Locked); + assert_eq!(dispute.release_at, 1000 + DISPUTE_TIMELOCK_SECS); + + // Fast forward to right before timelock release — resolution must fail. + env.ledger() + .with_mut(|li| li.timestamp = 1000 + DISPUTE_TIMELOCK_SECS - 1); + let result = client.try_resolve_dispute(&game_code, &Some(player1.clone())); + assert!(result.is_err()); + + // Fast forward past the 48-hour timelock — arbitration succeeds. + env.ledger() + .with_mut(|li| li.timestamp = 1000 + DISPUTE_TIMELOCK_SECS); + client.resolve_dispute(&game_code, &Some(player1.clone())); + + let match_data = client.get_match(&game_code); + assert_eq!(match_data.status, MatchStatus::Resolved); + assert_eq!(match_data.winner, Some(player1.clone())); + + let dispute_after = client.get_dispute(&game_code); + assert_eq!(dispute_after.status, DisputeStatus::Settled); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_raise_dispute_rejects_pending_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DISPUTE_ON_PENDING"); + approve(&env, &token, &player1, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + + client.raise_dispute(&game_code, &player1); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_raise_dispute_rejects_non_participant() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let outsider = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DISPUTE_NON_PARTICIPANT"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.raise_dispute(&game_code, &outsider); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_raise_dispute_rejects_duplicate() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DISPUTE_DUPLICATE"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.raise_dispute(&game_code, &player1); + client.raise_dispute(&game_code, &player2); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_dispute_timelock_blocks_resolve_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DISPUTE_BLOCKS_RESOLVE"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.raise_dispute(&game_code, &player1); + + // Coordinator's normal resolve path must be blocked while dispute is locked. + client.resolve_match(&game_code, &Some(player1.clone())); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_dispute_timelock_blocks_forfeit_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "DISPUTE_BLOCKS_FORFEIT"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.raise_dispute(&game_code, &player2); + client.forfeit_match(&game_code, &player1); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_dispute_timelock_blocks_claim_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| li.timestamp = 1000); + + let game_code = String::from_str(&env, "DISPUTE_BLOCKS_CLAIM"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + client.raise_dispute(&game_code, &player1); + + // Advance past match timeout, but dispute timelock still blocks refund claim. + env.ledger().with_mut(|li| li.timestamp = 4601); + client.claim_refund(&game_code, &player1); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_resolve_dispute_rejects_settled_dispute() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + env.ledger().with_mut(|li| li.timestamp = 1000); + + let game_code = String::from_str(&env, "DISPUTE_ALREADY_SETTLED"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + client.raise_dispute(&game_code, &player1); + + env.ledger() + .with_mut(|li| li.timestamp = 1000 + DISPUTE_TIMELOCK_SECS); + client.resolve_dispute(&game_code, &Some(player1.clone())); + + // Attempting to arbitrate the same (now-settled) dispute again must fail. + client.resolve_dispute(&game_code, &Some(player2.clone())); +} + +// --------------------------------------------------------------------------- +// Issue #23 — Atomic Batch Resolution +// --------------------------------------------------------------------------- + +#[test] +fn test_batch_resolve_matches_success() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &10000); + token_admin_client.mint(&player2, &10000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let g1 = String::from_str(&env, "BATCH_RESOLVE_1"); + let g2 = String::from_str(&env, "BATCH_RESOLVE_2"); + + approve(&env, &token, &player1, &contract_id, 10000); + approve(&env, &token, &player2, &contract_id, 10000); + + client.create_match(&g1, &player1, &token.address, &100); + client.join_match(&g1, &player2); + client.create_match(&g2, &player1, &token.address, &100); + client.join_match(&g2, &player2); + + let resolutions = vec![ + &env, + BatchResolution { + game_code: g1.clone(), + winner: Some(player1.clone()), + }, + BatchResolution { + game_code: g2.clone(), + winner: None, + }, + ]; + client.batch_resolve_matches(&resolutions); + + assert_eq!(client.get_match(&g1).status, MatchStatus::Resolved); + assert_eq!(client.get_match(&g1).winner, Some(player1.clone())); + assert_eq!(client.get_match(&g2).status, MatchStatus::Resolved); + assert_eq!(client.get_match(&g2).winner, None); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_batch_resolve_matches_rejects_empty() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + + let resolutions: Vec = vec![&env]; + client.batch_resolve_matches(&resolutions); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_batch_resolve_matches_rejects_oversized_batch() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &100000); + token_admin_client.mint(&player2, &100000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + approve(&env, &token, &player1, &contract_id, 100000); + approve(&env, &token, &player2, &contract_id, 100000); + + let mut resolutions: Vec = vec![&env]; + // MAX_BATCH_RESOLUTIONS is 10 — build 11 entries to exceed the limit. + for i in 0..11 { + let code = String::from_str( + &env, + match i { + 0 => "OVERSIZED_0", + 1 => "OVERSIZED_1", + 2 => "OVERSIZED_2", + 3 => "OVERSIZED_3", + 4 => "OVERSIZED_4", + 5 => "OVERSIZED_5", + 6 => "OVERSIZED_6", + 7 => "OVERSIZED_7", + 8 => "OVERSIZED_8", + 9 => "OVERSIZED_9", + _ => "OVERSIZED_10", + }, + ); + client.create_match(&code, &player1, &token.address, &100); + client.join_match(&code, &player2); + resolutions.push_back(BatchResolution { + game_code: code, + winner: Some(player1.clone()), + }); + } + + client.batch_resolve_matches(&resolutions); +} + +#[test] +#[should_panic(expected = "HostError")] +fn test_batch_resolve_matches_rejects_nonexistent_match() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + + let resolutions = vec![ + &env, + BatchResolution { + game_code: String::from_str(&env, "MISSING_GAME"), + winner: Some(player1), + }, + ]; + client.batch_resolve_matches(&resolutions); +} - // Token2 has no specific limits, falls back to global (100 to 1000) - assert_eq!(client.get_token_min_wager(&token2.address), 100); - assert_eq!(client.get_token_max_wager(&token2.address), 1000); +// --------------------------------------------------------------------------- +// On-Chain Player Elo Rating Ledger Proofs +// --------------------------------------------------------------------------- - approve(&env, &token1, &player1, &contract_id, 10000); - approve(&env, &token2, &player1, &contract_id, 10000); +#[test] +fn test_default_elo_is_1200() { + let env = Env::default(); + env.mock_all_auths(); - // Token1: 75 succeeds (between 50 and 500) - let g1 = String::from_str(&env, "GAME_TOK1_OK"); - client.create_match(&g1, &player1, &token1.address, &75); + let player = Address::generate(&env); + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); - // Token2: 75 fails because global min is 100 - let g2 = String::from_str(&env, "GAME_TOK2_FAIL"); - let res = client.try_create_match(&g2, &player1, &token2.address, &75); - assert!(res.is_err()); + client.init(&coordinator, &500); - // Remove token1 limits -> token1 now falls back to global (100 to 1000) - client.remove_token_wager_limits(&token1.address); - assert_eq!(client.get_token_min_wager(&token1.address), 100); - assert_eq!(client.get_token_max_wager(&token1.address), 1000); + let default_elo = client.get_player_elo(&player); + assert_eq!(default_elo, 1200); } #[test] -fn test_dynamic_wager_scaling() { +fn test_update_player_elo() { let env = Env::default(); env.mock_all_auths(); + let player = Address::generate(&env); let coordinator = Address::generate(&env); - let token_admin = Address::generate(&env); - let (token, _admin_client) = create_token_contract(&env, &token_admin); - let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); client.init(&coordinator, &500); - client.set_wager_limits(&100, &1000); - - // Scale up by 1.5x (15,000 bps) - client.scale_wager_limits(&15_000); - assert_eq!(client.get_min_wager(), 150); - assert_eq!(client.get_max_wager(), 1500); - - // Scale down by 50% (5,000 bps) - client.scale_wager_limits(&5_000); - assert_eq!(client.get_min_wager(), 75); - assert_eq!(client.get_max_wager(), 750); + client.update_player_elo(&player, &1500); - // Dynamic scaling for token-specific limits - client.set_token_wager_limits(&token.address, &200, &2000); - client.scale_token_wager_limits(&token.address, &20_000); // 2x - assert_eq!(client.get_token_min_wager(&token.address), 400); - assert_eq!(client.get_token_max_wager(&token.address), 4000); + let updated_elo = client.get_player_elo(&player); + assert_eq!(updated_elo, 1500); } #[test] -fn test_native_match_enforces_wager_limits() { - env.mock_all_auths(); +#[should_panic(expected = "HostError")] +fn test_update_player_elo_unauthorized() { + let env = Env::default(); + + let coordinator = Address::generate(&env); + let player = Address::generate(&env); + let unauthorized_user = Address::generate(&env); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &coordinator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "init", + args: (&coordinator, 500u32).into_val(&env), + sub_invokes: &[], + }, + }]); client.init(&coordinator, &500); + // Mock auth only for unauthorized_user, but require_auth() inside + // update_player_elo demands the `player` address's authorization. env.mock_auths(&[soroban_sdk::testutils::MockAuth { address: &unauthorized_user, invoke: &soroban_sdk::testutils::MockAuthInvoke { contract: &contract_id, fn_name: "update_player_elo", - args: vec![&env, player.into_val(&env), 1500u32.into_val(&env)], + args: (&player, 1500u32).into_val(&env), sub_invokes: &[], }, }]); @@ -1820,59 +3107,18 @@ fn test_update_player_elo_emits_event() { let env = Env::default(); env.mock_all_auths(); - let coordinator = Address::generate(&env); - let player1 = Address::generate(&env); - let token_admin = Address::generate(&env); - - let (token, token_admin_client) = create_token_contract(&env, &token_admin); - token_admin_client.mint(&player1, &1000); - let (native_token, token_admin_client) = create_token_contract(&env, &token_admin); - token_admin_client.mint(&player1, &10000); let player = Address::generate(&env); - + let coordinator = Address::generate(&env); let contract_id = env.register(ChessterEscrow, ()); let client = ChessterEscrowClient::new(&env, &contract_id); client.init(&coordinator, &500); - client.add_whitelisted_token(&token.address); - - let game_code = String::from_str(&env, "GAME_EVENT_CLAIM_REFUND"); - approve(&env, &token, &player1, &contract_id, 1000); - - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.create_match(&game_code, &player1, &token.address, &100); - - env.ledger().with_mut(|li| { - li.timestamp = 4601; - }); - - client.claim_refund(&game_code, &player1); - - let events = env.events().all(); - assert!(events.iter().any(|(id, _, _)| id == contract_id)); - client.set_native_xlm_address(&native_token.address); - client.set_wager_limits(&100, &1000); - - approve(&env, &native_token, &player1, &contract_id, 10000); - - // Native match below min fails - let g1 = String::from_str(&env, "NATIVE_BELOW_MIN"); - let res = client.try_create_native_match(&g1, &player1, &50); - assert!(res.is_err()); - - // Native match within limits succeeds - let g2 = String::from_str(&env, "NATIVE_WITHIN_LIMITS"); - client.create_native_match(&g2, &player1, &500); - let match_data = client.get_match(&g2); - assert_eq!(match_data.wager_amount, 500); let events_before = env.events().all().len(); client.update_player_elo(&player, &1600); let events_after = env.events().all().len(); - assert!(events_after >= events_before); + assert!(events_after > events_before); } // --------------------------------------------------------------------------- @@ -2218,3 +3464,173 @@ fn test_unpause_requires_coordinator_auth() { let result = client.try_unpause(); assert!(result.is_err()); } + +// --------------------------------------------------------------------------- +// Additional coordinator-only authorization checks +// --------------------------------------------------------------------------- + +#[test] +fn test_set_fee_bps_requires_coordinator_auth() { + let env = Env::default(); + let coordinator = Address::generate(&env); + let non_coordinator = Address::generate(&env); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &coordinator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "init", + args: (&coordinator, 500u32).into_val(&env), + sub_invokes: &[], + }, + }]); + client.init(&coordinator, &500); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &non_coordinator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "set_fee_bps", + args: (1000u32,).into_val(&env), + sub_invokes: &[], + }, + }]); + let result = client.try_set_fee_bps(&1000); + assert!(result.is_err()); +} + +#[test] +fn test_add_whitelisted_token_requires_coordinator_auth() { + let env = Env::default(); + let coordinator = Address::generate(&env); + let non_coordinator = Address::generate(&env); + let token_admin = Address::generate(&env); + + env.mock_all_auths(); + let (token, _admin_client) = create_token_contract(&env, &token_admin); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + client.init(&coordinator, &500); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &non_coordinator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "add_whitelisted_token", + args: (token.address.clone(),).into_val(&env), + sub_invokes: &[], + }, + }]); + let result = client.try_add_whitelisted_token(&token.address); + assert!(result.is_err()); +} + +#[test] +fn test_resolve_match_requires_coordinator_auth() { + let env = Env::default(); + let coordinator = Address::generate(&env); + let non_coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + env.mock_all_auths(); + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &1000); + token_admin_client.mint(&player2, &1000); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + client.init(&coordinator, &500); + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "RESOLVE_AUTH_GAME"); + approve(&env, &token, &player1, &contract_id, 1000); + approve(&env, &token, &player2, &contract_id, 1000); + client.create_match(&game_code, &player1, &token.address, &100); + client.join_match(&game_code, &player2); + + // From here on, mock only non_coordinator's auth -- resolve_match still + // needs the real coordinator's authorization internally. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &non_coordinator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "resolve_match", + args: (game_code.clone(), Some(player1.clone())).into_val(&env), + sub_invokes: &[], + }, + }]); + let result = client.try_resolve_match(&game_code, &Some(player1.clone())); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Overflow-ish wager / fee calculation checks +// --------------------------------------------------------------------------- + +#[test] +fn test_resolve_match_handles_very_large_wager() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + let token_admin = Address::generate(&env); + + // Safe upper bound that avoids overflow in `total_staked * admin_bps` + // (total_staked = 2 * wager, admin_bps = 500). + let large_wager: i128 = i128::MAX / 2000; + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + token_admin_client.mint(&player1, &large_wager); + token_admin_client.mint(&player2, &large_wager); + + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); // 5% fee + client.add_whitelisted_token(&token.address); + + let game_code = String::from_str(&env, "LARGE_WAGER_GAME"); + approve(&env, &token, &player1, &contract_id, large_wager); + approve(&env, &token, &player2, &contract_id, large_wager); + client.create_match(&game_code, &player1, &token.address, &large_wager); + client.join_match(&game_code, &player2); + + let total_staked = large_wager * 2; + let expected_fee = (total_staked * 500) / 10000; + let expected_payout = total_staked - expected_fee; + + client.resolve_match(&game_code, &Some(player1.clone())); + + assert_eq!(token.balance(&player1), expected_payout); + assert_eq!(token.balance(&coordinator), expected_fee); + assert_eq!(token.balance(&contract_id), 0); +} + +#[test] +fn test_scale_wager_limits_with_near_max_values() { + let env = Env::default(); + env.mock_all_auths(); + + let coordinator = Address::generate(&env); + let contract_id = env.register(ChessterEscrow, ()); + let client = ChessterEscrowClient::new(&env, &contract_id); + + client.init(&coordinator, &500); + + // Set a very large but finite max wager, then scale it down. + let big_max: i128 = i128::MAX / 4; + client.set_wager_limits(&1_000_000, &big_max); + + client.scale_wager_limits(&5_000); // 50% + assert_eq!(client.get_min_wager(), 500_000); + assert_eq!(client.get_max_wager(), big_max / 2); +} + diff --git a/frontend/.gitignore b/frontend/.gitignore index 966097b..01ea531 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,3 +24,9 @@ database *.njsproj *.sln *.sw? + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/frontend/e2e/gameplay.spec.ts b/frontend/e2e/gameplay.spec.ts new file mode 100644 index 0000000..6e2b78b --- /dev/null +++ b/frontend/e2e/gameplay.spec.ts @@ -0,0 +1,140 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * End-to-end game lifecycle: two independent browser contexts (one per + * player) create/join a game, play a full legal short game to checkmate + * ("Fool's Mate": 1.f3 e5 2.g4 Qh4#), and both see the correct game-over + * outcome. Runs against the real frontend UI; the backend + wallet are the + * fixtures described in playwright.config.ts and e2e/stubs. + */ + +async function setFakeWallet(page: Page, address: string) { + await page.addInitScript((addr) => { + window.localStorage.setItem("__e2e_wallet_address__", addr); + }, address); +} + +async function clickSquare(page: Page, row: number, col: number) { + await page.getByTestId(`square-${row}-${col}`).click(); +} + +test.describe("Full game lifecycle", () => { + test("two players connect, play, and reach checkmate", async ({ browser }) => { + const whiteContext = await browser.newContext(); + const blackContext = await browser.newContext(); + const white = await whiteContext.newPage(); + const black = await blackContext.newPage(); + + await setFakeWallet(white, "GWHITEPLAYERFAKEADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + await setFakeWallet(black, "GBLACKPLAYERFAKEADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + + // ── White creates a (non-wagered) game ────────────────────────────── + // The fake wallet address is already in localStorage before the app's + // own checkConnection() runs on mount, so both players auto-connect + // without needing to click "Connect Freighter". + await white.goto("/"); + await expect(white.getByTestId("create-game-btn")).toBeEnabled(); + await white.getByTestId("create-game-btn").click(); + + // Creating navigates to /:gameCode and lands on the waiting-room view. + await white.waitForURL(/\/[A-Z0-9]{4,}$/); + const gameCode = white.url().split("/").pop()!; + await expect(white.getByTestId("game-code-display")).toContainText(gameCode); + + // ── Black joins by code ────────────────────────────────────────────── + await black.goto("/"); + await black.getByTestId("join-code-input").fill(gameCode); + await expect(black.getByTestId("join-game-btn")).toBeEnabled(); + await black.getByTestId("join-game-btn").click(); + await black.waitForURL(new RegExp(`/${gameCode}$`)); + + // Once black joins, the game goes active and white's waiting screen + // (driven by the real-time "game-update" socket broadcast) resolves + // into the live board. + await expect(white.getByTestId("square-6-5")).toBeVisible(); + await expect(black.getByTestId("square-6-5")).toBeVisible(); + + // ── 1. f3 (white) ───────────────────────────────────────────────── + await clickSquare(white, 6, 5); // select f2 pawn + await clickSquare(white, 5, 5); // move to f3 + await expect(black.getByTestId("square-5-5")).toHaveText("♙"); + + // ── 1... e5 (black) ─────────────────────────────────────────────── + await clickSquare(black, 1, 4); // select e7 pawn + await clickSquare(black, 3, 4); // move to e5 + await expect(white.getByTestId("square-3-4")).toHaveText("♟"); + + // ── 2. g4 (white) ───────────────────────────────────────────────── + await clickSquare(white, 6, 6); // select g2 pawn + await clickSquare(white, 4, 6); // move to g4 + await expect(black.getByTestId("square-4-6")).toHaveText("♙"); + + // ── 2... Qh4# (black delivers checkmate) ───────────────────────── + await clickSquare(black, 0, 3); // select d8 queen + await clickSquare(black, 4, 7); // move to h4 — checkmate + await expect(white.getByTestId("square-4-7")).toHaveText("♛"); + + // ── Both sides observe the same final outcome in real time ──────── + const whiteBanner = white.getByTestId("game-over-banner"); + const blackBanner = black.getByTestId("game-over-banner"); + await expect(whiteBanner).toBeVisible(); + await expect(blackBanner).toBeVisible(); + + await expect(whiteBanner).toHaveAttribute("data-winner", "black"); + await expect(blackBanner).toHaveAttribute("data-winner", "black"); + await expect(whiteBanner).toHaveText("You lose"); + await expect(blackBanner).toHaveText("You win!"); + + await whiteContext.close(); + await blackContext.close(); + }); + + test("room isolation: a spectator on a different game never sees this game's moves", async ({ + browser, + }) => { + const whiteContext = await browser.newContext(); + const blackContext = await browser.newContext(); + const otherContext = await browser.newContext(); + const white = await whiteContext.newPage(); + const black = await blackContext.newPage(); + const other = await otherContext.newPage(); + + await setFakeWallet(white, "GWHITE2FAKEADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + await setFakeWallet(black, "GBLACK2FAKEADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + await setFakeWallet(other, "GOTHERFAKEADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"); + + // Game A: white + black. + await white.goto("/"); + await expect(white.getByTestId("create-game-btn")).toBeEnabled(); + await white.getByTestId("create-game-btn").click(); + await white.waitForURL(/\/[A-Z0-9]{4,}$/); + const gameCodeA = white.url().split("/").pop()!; + + await black.goto("/"); + await black.getByTestId("join-code-input").fill(gameCodeA); + await expect(black.getByTestId("join-game-btn")).toBeEnabled(); + await black.getByTestId("join-game-btn").click(); + await black.waitForURL(new RegExp(`/${gameCodeA}$`)); + + // Game B: an unrelated game the "other" client creates and stays in. + await other.goto("/"); + await expect(other.getByTestId("create-game-btn")).toBeEnabled(); + await other.getByTestId("create-game-btn").click(); + await other.waitForURL(/\/[A-Z0-9]{4,}$/); + + await expect(white.getByTestId("square-6-5")).toBeVisible(); + + // A move in game A must not surface on the unrelated "other" client. + await clickSquare(white, 6, 5); + await clickSquare(white, 5, 5); + await expect(black.getByTestId("square-5-5")).toHaveText("♙"); + + // "Other" is still on its own waiting-room screen for a different + // game code and was never subscribed to game A's room. + await expect(other.getByTestId("game-code-display")).not.toContainText(gameCodeA); + + await whiteContext.close(); + await blackContext.close(); + await otherContext.close(); + }); +}); diff --git a/frontend/e2e/mock-backend/server.cjs b/frontend/e2e/mock-backend/server.cjs new file mode 100644 index 0000000..696278e --- /dev/null +++ b/frontend/e2e/mock-backend/server.cjs @@ -0,0 +1,224 @@ +/** + * Minimal in-memory backend used only for Playwright E2E runs. + * + * The real backend needs live Supabase + Soroban RPC credentials that this + * sandbox/CI does not have. This fixture implements just the REST + Socket.io + * contract the frontend actually calls (see src/api/gameApi.ts and + * src/api/socket.ts) so `frontend/e2e/gameplay.spec.ts` can drive the real + * frontend UI through a full two-player game end to end. It trusts whatever + * moves the test sends (no chess legality engine) — the spec plays a fixed, + * legal "Fool's Mate" script and this server just applies those exact + * from/to moves to the board and recognizes the scripted checkmate. + */ + +const http = require("http"); +const { Server } = require("socket.io"); + +const PORT = process.env.MOCK_BACKEND_PORT || 4310; + +function initialBoard() { + return [ + ["r", "n", "b", "q", "k", "b", "n", "r"], + ["p", "p", "p", "p", "p", "p", "p", "p"], + [".", ".", ".", ".", ".", ".", ".", "."], + [".", ".", ".", ".", ".", ".", ".", "."], + [".", ".", ".", ".", ".", ".", ".", "."], + [".", ".", ".", ".", ".", ".", ".", "."], + ["P", "P", "P", "P", "P", "P", "P", "P"], + ["R", "N", "B", "Q", "K", "B", "N", "R"], + ]; +} + +const games = new Map(); + +function randomCode() { + return Math.random().toString(36).substring(2, 8).toUpperCase(); +} + +function publicGame(game) { + return { + game_code: game.game_code, + status: game.status, + board_state: game.board_state, + current_turn: game.current_turn, + in_check: game.in_check, + winner: game.winner, + end_reason: game.end_reason, + draw_offer: game.draw_offer, + turn_started_at: game.turn_started_at, + time_control_seconds: game.time_control_seconds, + game_started_at: game.game_started_at, + captured_white: game.captured_white, + captured_black: game.captured_black, + last_move: game.last_move, + wager_amount: game.wager_amount, + token_address: game.token_address, + escrow_status: game.escrow_status, + player_white_address: game.player_white_address, + player_black_address: game.player_black_address, + }; +} + +function send(res, status, body) { + res.writeHead(status, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + }); + res.end(JSON.stringify(body)); +} + +function readBody(req) { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch { + resolve({}); + } + }); + }); +} + +const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + send(res, 204, {}); + return; + } + + const url = new URL(req.url, `http://${req.headers.host}`); + const parts = url.pathname.split("/").filter(Boolean); // ["api", "games", ...] + + if (parts[0] !== "api" || parts[1] !== "games") { + send(res, 404, { success: false, error: "not found" }); + return; + } + + // POST /api/games + if (parts.length === 2 && req.method === "POST") { + const body = await readBody(req); + const game_code = (body.gameCode || randomCode()).toUpperCase(); + const game = { + game_code, + status: "waiting", + board_state: initialBoard(), + current_turn: "white", + in_check: false, + winner: null, + end_reason: null, + draw_offer: null, + turn_started_at: null, + time_control_seconds: body.timeControlSeconds || 600, + game_started_at: null, + captured_white: [], + captured_black: [], + last_move: null, + wager_amount: body.wagerAmount || null, + token_address: null, + escrow_status: null, + player_white_address: body.playerWhiteAddress || null, + player_black_address: null, + moveCount: 0, + }; + games.set(game_code, game); + send(res, 201, { success: true, data: publicGame(game) }); + return; + } + + // GET /api/games/:code + if (parts.length === 3 && req.method === "GET") { + const game = games.get(parts[2]); + if (!game) { + send(res, 404, { success: false, error: "Game not found" }); + return; + } + send(res, 200, { success: true, data: publicGame(game) }); + return; + } + + // GET /api/games/:code/chat + if (parts.length === 4 && parts[3] === "chat" && req.method === "GET") { + send(res, 200, { success: true, data: [] }); + return; + } + + // POST /api/games/:code/join + if (parts.length === 4 && parts[3] === "join" && req.method === "POST") { + const game = games.get(parts[2]); + if (!game) { + send(res, 400, { success: false, error: "Game not found" }); + return; + } + const body = await readBody(req); + if (body.playerColor === "black") { + game.player_black_address = body.playerAddress || null; + game.status = "active"; + game.game_started_at = new Date().toISOString(); + } else { + game.player_white_address = body.playerAddress || null; + } + send(res, 200, { success: true, data: publicGame(game) }); + io.to(game.game_code).emit("game-update", publicGame(game)); + return; + } + + // POST /api/games/:code/move + if (parts.length === 4 && parts[3] === "move" && req.method === "POST") { + const game = games.get(parts[2]); + if (!game) { + send(res, 400, { success: false, error: "Game not found" }); + return; + } + if (game.status !== "active") { + send(res, 400, { success: false, error: "Game is not active" }); + return; + } + const body = await readBody(req); + const [fr, fc] = body.from; + const [tr, tc] = body.to; + const piece = game.board_state[fr][fc]; + game.board_state[fr][fc] = "."; + game.board_state[tr][tc] = body.promotion + ? piece === piece.toUpperCase() + ? body.promotion.toUpperCase() + : body.promotion.toLowerCase() + : piece; + game.last_move = { from: body.from, to: body.to, piece }; + game.moveCount += 1; + game.current_turn = game.current_turn === "white" ? "black" : "white"; + + // Scripted "Fool's Mate" recognition: 1.f3 e5 2.g4 Qh4# — the 4th + // half-move (black queen d8->h4) delivers checkmate against white. + if (game.moveCount === 4 && fr === 0 && fc === 3 && tr === 4 && tc === 7) { + game.status = "finished"; + game.winner = "black"; + game.end_reason = "checkmate"; + game.in_check = true; + } + + send(res, 200, { success: true, data: publicGame(game) }); + io.to(game.game_code).emit("game-update", publicGame(game)); + return; + } + + send(res, 404, { success: false, error: "not found" }); +}); + +const io = new Server(server, { cors: { origin: "*", methods: ["GET", "POST"] } }); + +io.on("connection", (socket) => { + socket.on("join-game", (gameCode) => { + if (typeof gameCode === "string") socket.join(gameCode); + else if (gameCode && gameCode.gameCode) socket.join(gameCode.gameCode); + }); + socket.on("leave-game", (gameCode) => { + if (typeof gameCode === "string") socket.leave(gameCode); + }); +}); + +server.listen(PORT, () => { + console.log(`Mock E2E backend listening on port ${PORT}`); +}); diff --git a/frontend/e2e/stubs/freighterApiStub.ts b/frontend/e2e/stubs/freighterApiStub.ts new file mode 100644 index 0000000..f468921 --- /dev/null +++ b/frontend/e2e/stubs/freighterApiStub.ts @@ -0,0 +1,50 @@ +/** + * Deterministic stand-in for @stellar/freighter-api, used only when + * VITE_E2E=true (see vite.config.ts). There is no real Freighter browser + * extension available under Playwright, so this stub lets e2e specs drive + * "wallet connection" by writing a fake address to localStorage before + * navigating, via `page.addInitScript`. + * + * The fake address key is intentionally distinct per browser context so two + * Playwright contexts (white / black) each get their own identity. + */ + +const STORAGE_KEY = "__e2e_wallet_address__"; + +function readFakeAddress(): string | null { + try { + return window.localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export async function requestAccess(): Promise<{ address?: string; error?: string }> { + const address = readFakeAddress(); + if (!address) return { error: "No e2e wallet address configured" }; + return { address }; +} + +export async function isConnected(): Promise<{ isConnected: boolean }> { + return { isConnected: !!readFakeAddress() }; +} + +export async function getAddress(): Promise<{ address?: string }> { + const address = readFakeAddress(); + return address ? { address } : {}; +} + +export async function signTransaction(): Promise<{ signedTxXdr: string; error?: string }> { + // Not exercised by the non-wagered e2e gameplay flow; present only to + // satisfy the module's export surface. + return { signedTxXdr: "" }; +} + +export class WatchWalletChanges { + watch(_callback: (params: { address?: string }) => void): void { + // No-op: the e2e stub address never changes after connect. + } + stop(): void { + // No-op. + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 261fa0d..bb62d64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.62.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -30,6 +31,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "socket.io": "^4.8.3", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1", @@ -70,6 +72,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1042,9 +1045,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1075,6 +1075,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1167,9 +1183,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1183,9 +1196,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1199,9 +1209,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1215,9 +1222,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1231,9 +1235,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1247,9 +1248,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1263,9 +1261,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1279,9 +1274,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1295,9 +1287,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1311,9 +1300,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1327,9 +1313,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1343,9 +1326,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1359,9 +1339,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1630,9 +1607,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1649,9 +1623,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1668,9 +1639,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1687,9 +1655,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1830,6 +1795,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1856,6 +1831,7 @@ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1866,6 +1842,7 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1880,6 +1857,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -1925,6 +1912,7 @@ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", @@ -2202,6 +2190,7 @@ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.11", @@ -2340,12 +2329,27 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2498,6 +2502,16 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", @@ -2548,6 +2562,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -2725,6 +2740,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2810,6 +2843,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, "node_modules/engine.io-client": { "version": "6.6.6", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", @@ -2832,6 +2887,16 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -2968,6 +3033,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3905,9 +3971,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3928,9 +3991,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3951,9 +4011,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3974,9 +4031,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4183,6 +4237,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -4193,6 +4257,16 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4308,6 +4382,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4315,6 +4390,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -4377,6 +4499,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4386,6 +4509,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4562,6 +4686,36 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -4733,6 +4887,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4825,11 +4980,22 @@ "punycode": "^2.1.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -4905,6 +5071,7 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", @@ -5087,6 +5254,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index d7b8d58..b1746f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.62.1", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -37,6 +38,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "socket.io": "^4.8.3", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..dd60b0f --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; + +const MOCK_BACKEND_PORT = process.env.MOCK_BACKEND_PORT || "4310"; +const FRONTEND_PORT = 3090; +const BACKEND_URL = `http://localhost:${MOCK_BACKEND_PORT}/`; + +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: "list", + use: { + baseURL: `http://localhost:${FRONTEND_PORT}`, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: [ + { + command: "node e2e/mock-backend/server.cjs", + port: Number(MOCK_BACKEND_PORT), + reuseExistingServer: !process.env.CI, + env: { MOCK_BACKEND_PORT }, + }, + { + command: "npm run dev", + port: FRONTEND_PORT, + reuseExistingServer: !process.env.CI, + env: { + VITE_E2E: "true", + VITE_BACKEND_URL: BACKEND_URL, + }, + }, + ], +}); diff --git a/frontend/src/components/ChessBoard.tsx b/frontend/src/components/ChessBoard.tsx index 86031af..9aa04c1 100644 --- a/frontend/src/components/ChessBoard.tsx +++ b/frontend/src/components/ChessBoard.tsx @@ -174,6 +174,7 @@ function WaitingScreen() { {/* Copyable game code */} {gameCode} @@ -688,6 +689,7 @@ function ChessBoardInner() { return ( your turn next )} {status === "finished" && ( - + {winner === "draw" ? "Draw!" : winner === playerColor ? "You win!" : "You lose"} )} diff --git a/frontend/src/components/GameLobby.tsx b/frontend/src/components/GameLobby.tsx index 51cef34..fa8ae94 100644 --- a/frontend/src/components/GameLobby.tsx +++ b/frontend/src/components/GameLobby.tsx @@ -522,7 +522,7 @@ export default function GameLobby() { {isConnected ? ( ) : ( - + Connect Freighter )} @@ -627,6 +627,7 @@ export default function GameLobby() { {/* Create button */} @@ -671,10 +672,12 @@ export default function GameLobby() { } maxLength={10} disabled={isLoading} + data-testid="join-code-input" className="w-full px-4 py-3 text-base border border-(--border) rounded-xl text-center uppercase outline-none focus:ring-2 focus:ring-(--accent-primary) bg-(--bg) text-(--text) placeholder:text-(--text-tertiary) transition-all tracking-widest font-mono disabled:opacity-50" /> diff --git a/frontend/src/pages/SpectatorPage.tsx b/frontend/src/pages/SpectatorPage.tsx index 9950791..c1a4d56 100644 --- a/frontend/src/pages/SpectatorPage.tsx +++ b/frontend/src/pages/SpectatorPage.tsx @@ -190,12 +190,12 @@ export default function SpectatorPage() { - {TrendingUp size={12} className="text-white" /> + {whiteMaterial} {blackMaterial} - {TrendingDown size={12} className="text-gray-400" /> + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 922a268..58b3f6f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -5,6 +6,21 @@ import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + resolve: { + alias: + // Playwright E2E runs have no real Freighter browser extension to talk + // to. Swap the wallet SDK for a deterministic stub (only when + // VITE_E2E=true is set by playwright.config.ts) so e2e specs can drive + // wallet "connection" without touching any application source. + process.env.VITE_E2E === "true" + ? { + "@stellar/freighter-api": path.resolve( + __dirname, + "e2e/stubs/freighterApiStub.ts", + ), + } + : {}, + }, server: { port: 3090, // host: true, @@ -12,6 +28,7 @@ export default defineConfig({ test: { globals: true, environment: "node", + exclude: ["**/node_modules/**", "e2e/**"], coverage: { provider: "v8", reporter: ["text", "json", "html", "lcov"],