Files
tiennm99 35999a809d fix(api): give every store call a deadline, harden the small things
Every Upstash command and Neon query now has a deadline (5 s and 8 s): the
Redis client retried a hung connection past the browser's fifteen-second
wait, and the Neon client had no limit. Usernames are NFC-normalised so a
tone mark typed as a combining character is accepted and does not make two
board members of one name. The debug key is compared in constant time. A
panorama whose district the tree does not know fails the draw before a
session is written, not /api/guess after it is consumed. The country branch
of countPanos and the never-triggered per-city cap are gone.
2026-09-29 20:13:07 +07:00

58 lines
2.1 KiB
JavaScript

import { describe, expect, it } from 'vitest';
import { generateRandomUsername, validateUsername } from '../src/lib/username.js';
// The generated name must satisfy the same rules UsernameModal enforces,
// because it is stored and later edited exactly like a typed name.
describe('generateRandomUsername', () => {
it('produces a name the modal validation accepts', () => {
for (let i = 0; i < 50; i += 1) {
const name = generateRandomUsername();
expect(name.length).toBeGreaterThanOrEqual(2);
expect(name.length).toBeLessThanOrEqual(20);
expect(name).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(validateUsername(name).ok).toBe(true);
}
});
it('uses the Player- prefix so generated names are recognisable', () => {
expect(generateRandomUsername()).toMatch(/^Player-[0-9a-z]{6}$/);
});
});
// One rule for the modal and /api/guess: the browser used to be the only
// validator, so a request built by hand could put any string on the boards.
describe('validateUsername', () => {
it('trims and accepts an ordinary name', () => {
expect(validateUsername(' mai_99 ')).toEqual({ ok: true, value: 'mai_99', error: null });
});
it('accepts Vietnamese letters', () => {
expect(validateUsername('Tiến').ok).toBe(true);
expect(validateUsername('Nguyễn-Văn').ok).toBe(true);
});
it('composes a name typed with combining tone marks into the same member', () => {
// 'ả' as a plain 'a' followed by U+0309 HOOK ABOVE, which is what some
// Vietnamese keyboards and IMEs produce.
const decomposed = 'Tra\u0309n_ok';
const composed = 'Trản_ok';
expect(decomposed).not.toBe(composed);
expect(validateUsername(decomposed)).toEqual({ ok: true, value: composed, error: null });
});
it.each([
['not a string', 123],
['empty', ' '],
['too short', 'a'],
['too long', 'x'.repeat(21)],
['a space inside', 'mai le'],
// ':' is the separator inside distance-board members.
['a colon', 'mai:5'],
['punctuation', 'mai!'],
])('rejects %s', (_label, raw) => {
const result = validateUsername(raw);
expect(result.ok).toBe(false);
expect(result.error).toBeTruthy();
});
});