v0.12.2 drop version from api path, deploy script update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-27 02:30:00 -04:00
1 parent 1e7d759a19
commit 447d6eedcc
24 files changed
+658 -650

No files matched your search

+2 -2
View File
@@ -96,8 +96,8 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
// Health check (no rate limit)
app.Get("/health", h.Health)
// API v1 routes
api := app.Group("/api/v1")
// API routes
api := app.Group("/api")
// Auth routes with specific rate limiting
auth := api.Group("/auth")
+44
View File
@@ -3,8 +3,11 @@ package http
import (
"io"
"net/http/httptest"
"strings"
"testing"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
@@ -59,3 +62,44 @@ func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
}
}
}
func TestAPIRoutesAreUnversioned(t *testing.T) {
svc, err := service.New(nil, []byte("test-secret-test-secret-test-secret"))
if err != nil {
t.Fatal(err)
}
// Route registration only; no handler runs, so no processor is needed.
app := NewFiberApp(nil, svc, Options{DevMode: true})
registered := make(map[string]bool)
for _, route := range app.GetRoutes(true) {
registered[route.Method+" "+route.Path] = true
if strings.HasPrefix(route.Path, "/api/v1") {
t.Errorf("versioned route still registered: %s %s", route.Method, route.Path)
}
}
for _, want := range []string{
"GET /health",
"POST /api/auth/register",
"POST /api/auth/login",
"GET /api/auth/me",
"POST /api/auth/logout",
"POST /api/games",
"GET /api/games/:gameId",
"GET /api/games/:gameId/history",
"POST /api/games/:gameId/moves",
"GET /api/users/me/games",
} {
if !registered[want] {
t.Errorf("route %s is not registered", want)
}
}
response, err := app.Test(httptest.NewRequest("GET", "/api/v1/games/5579b47e-4d3b-4eb3-abbd-846fe94cb955", nil))
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusNotFound {
t.Fatalf("GET /api/v1/... status = %d, want 404", response.StatusCode)
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ func (s *Service) GenerateGameID() string {
// ApplyMoveWithState verifies that the position validated by the processor is
// still current, then commits the move, optional first-move claim, and result as
// one in-memory transition and one SQLite transaction.
// one in-memory transition and one database transaction.
func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -92,7 +92,7 @@ function restoreAuthSession() {
async function validateSession() {
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/auth/me`);
const response = await authFetch(`${gameState.apiUrl}/api/auth/me`);
if (response.ok) {
const user = await response.json();
gameState.userId = user.userId;
@@ -228,7 +228,7 @@ async function handleLogin() {
let response;
try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
response = await fetch(`${gameState.apiUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password })
@@ -295,7 +295,7 @@ async function handleRegister() {
let response;
try {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, {
response = await fetch(`${gameState.apiUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -337,7 +337,7 @@ async function handleRegister() {
async function handleLogout() {
if (gameState.authToken) {
try {
await authFetch(`${gameState.apiUrl}/api/v1/auth/logout`, { method: 'POST' });
await authFetch(`${gameState.apiUrl}/api/auth/logout`, { method: 'POST' });
} catch {
// Ignore - clear local state regardless
}
@@ -619,7 +619,7 @@ async function startNewGame() {
let response;
try {
response = await authFetch(`${gameState.apiUrl}/api/v1/games`, {
response = await authFetch(`${gameState.apiUrl}/api/games`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
@@ -771,7 +771,7 @@ async function handleHumanMove(from, to) {
const toEl = document.querySelector(`[data-square="${to}"]`);
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/moves`, {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move })
@@ -808,7 +808,7 @@ async function handleHumanMove(from, to) {
async function triggerComputerMove() {
lockBoard();
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/moves`, {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/moves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ move: 'cccc' })
@@ -849,7 +849,7 @@ async function pollOnce() {
try {
const response = await authFetch(
`${gameState.apiUrl}/api/v1/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`,
`${gameState.apiUrl}/api/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`,
{ signal: gameState.pollController.signal }
);
@@ -908,7 +908,7 @@ async function undoMoves() {
}
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
const response = await authFetch(`${gameState.apiUrl}/api/games/${gameState.gameId}/undo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 2 })
@@ -511,20 +511,28 @@ main {
background: var(--host-surface);
border: 1px solid var(--tokyo-border);
border-radius: 12px;
padding: 2rem;
padding: 1.5rem 2rem; /* was 2rem */
width: 90%;
max-width: 400px;
color: var(--tokyo-fg);
/* Last resort only. The spacing below is sized so the tallest modal
(New Game, ~425px) clears the 30rem embed on lixen.com without this ever
engaging — it exists for odd zoom levels and very short windows. The
overlay is fixed to the IFRAME viewport, so without it the modal clips
instead of scrolling the page. */
max-height: calc(100% - 2rem);
overflow-y: auto;
overscroll-behavior: contain;
}
.modal h2 {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
color: var(--host-royal);
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
.form-group label {
@@ -592,7 +600,7 @@ input[type="range"]::-webkit-slider-thumb {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
margin-top: 1.5rem;
}
/* Auth Tabs */
@@ -761,7 +769,13 @@ input[type="range"]:disabled {
flex-shrink: 0;
}
/* Board edge = the smaller of the width-derived size and the height that is
actually left. Reserve = info-panel 200 + main gap 8 + .container padding
32 + .outer-container padding 16 = 256, plus 16 slack. A short frame now
shrinks the board instead of scrolling; overflow-y stays as last resort. */
.game-area {
--board-pad: clamp(16px, 2.04vw, 20px);
--board-edge: max(280px, min(clamp(360px, 45vw, 440px), calc(100vh - 272px)));
min-width: auto;
height: auto;
display: flex;
@@ -770,9 +784,9 @@ input[type="range"]:disabled {
}
.board-container {
width: clamp(360px, 45vw, 440px);
height: clamp(360px, 45vw, 440px);
padding: clamp(16px, 2.04vw, 20px);
width: var(--board-edge);
height: var(--board-edge);
padding: var(--board-pad);
display: flex;
align-items: center;
justify-content: center;
@@ -781,8 +795,8 @@ input[type="range"]:disabled {
}
.board-wrapper {
width: clamp(328px, 41vw, 400px);
height: clamp(328px, 41vw, 400px);
width: calc(var(--board-edge) - 2 * var(--board-pad));
height: calc(var(--board-edge) - 2 * var(--board-pad));
position: relative;
margin: 0;
}
@@ -882,15 +896,12 @@ input[type="range"]:disabled {
body { min-width: 0; overflow-x: hidden; }
.outer-container { width: 100%; min-width: 0; }
.container { width: calc(100% - 16px); min-width: 0; }
.board-container {
width: min(92vw, 440px);
height: min(92vw, 440px);
padding: 12px;
}
.board-wrapper {
width: calc(min(92vw, 440px) - 24px);
height: calc(min(92vw, 440px) - 24px);
.game-area {
--board-pad: 12px;
--board-edge: max(240px, min(min(92vw, 440px), calc(100vh - 272px)));
}
.info-panel {
width: min(92vw, 440px);
min-width: 0;