From c9661007a1e765566e20e94026a56dc11d64658a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Fri, 25 Sep 2026 13:54:29 +0200 Subject: [PATCH] feat(gaming): look up a public game by slug Add GET /gaming/games/by-slug/{slug}, returning the same payload as the id route and 404ing inactive or unplayable games. --- .../core/src/casino/gaming/contract/index.ts | 5 + .../core/src/casino/gaming/router/index.ts | 6 ++ .../casino/gaming/service/gaming.service.ts | 23 +++- .../__tests__/gaming-game-by-slug.e2e.test.ts | 102 ++++++++++++++++++ 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 packages/testing/src/__tests__/gaming-game-by-slug.e2e.test.ts diff --git a/packages/core/src/casino/gaming/contract/index.ts b/packages/core/src/casino/gaming/contract/index.ts index 8ff0de52..283a31dd 100644 --- a/packages/core/src/casino/gaming/contract/index.ts +++ b/packages/core/src/casino/gaming/contract/index.ts @@ -138,6 +138,11 @@ export const gamingContract = { .input(IdInputSchema) .output(GameSchema), + getGameBySlug: oc + .route({ method: 'GET', path: '/gaming/games/by-slug/{slug}' }) + .input(z.object({ slug: CatalogSlugSchema })) + .output(GameSchema), + startRound: oc .route({ method: 'POST', path: '/gaming/rounds/start' }) .input(StartRoundInputSchema) diff --git a/packages/core/src/casino/gaming/router/index.ts b/packages/core/src/casino/gaming/router/index.ts index 8c77713a..b400adb4 100644 --- a/packages/core/src/casino/gaming/router/index.ts +++ b/packages/core/src/casino/gaming/router/index.ts @@ -93,6 +93,12 @@ export function createGamingRouter({ ), ), + getGameBySlug: os.getGameBySlug.handler(({ input }) => + mapErrors({ NOT_FOUND: GameNotFoundError }, () => + gaming.getGameBySlug(input.slug, { activeOnly: true }), + ), + ), + startRound: os.startRound.handler(({ input, context }) => mapErrors( { diff --git a/packages/core/src/casino/gaming/service/gaming.service.ts b/packages/core/src/casino/gaming/service/gaming.service.ts index 6336d745..9999ef67 100644 --- a/packages/core/src/casino/gaming/service/gaming.service.ts +++ b/packages/core/src/casino/gaming/service/gaming.service.ts @@ -501,19 +501,34 @@ export class GamingService { async getGame( id: Game['id'], opts: { activeOnly?: boolean; includeInvisibleTags?: boolean } = {}, + ) { + return this.findGame(eq(game.id, id), id, opts); + } + + async getGameBySlug( + slug: Game['slug'], + opts: { activeOnly?: boolean; includeInvisibleTags?: boolean } = {}, + ) { + return this.findGame(eq(game.slug, slug), slug, opts); + } + + private async findGame( + where: SQL, + key: string, + opts: { activeOnly?: boolean; includeInvisibleTags?: boolean }, ) { const row = findOneOrThrow( await this.drizzle.db .select({ game, provider: gameProvider }) .from(game) .innerJoin(gameProvider, eq(game.providerId, gameProvider.id)) - .where(eq(game.id, id)), - new GameNotFoundError(id), + .where(where), + new GameNotFoundError(key), ); - // The public detail route passes activeOnly: internal callers (updateGame's + // The public detail routes pass activeOnly: internal callers (updateGame's // return value) keep the unfiltered row so an admin still sees what they wrote. if (opts.activeOnly && !isGamePlayable(row.game, row.provider)) { - throw new GameNotFoundError(id); + throw new GameNotFoundError(key); } const [categories, tags] = await Promise.all([ categoriesByGameIds(this.drizzle.db, [row.game.id], opts.activeOnly), diff --git a/packages/testing/src/__tests__/gaming-game-by-slug.e2e.test.ts b/packages/testing/src/__tests__/gaming-game-by-slug.e2e.test.ts new file mode 100644 index 00000000..07c5f0d3 --- /dev/null +++ b/packages/testing/src/__tests__/gaming-game-by-slug.e2e.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { game, gameProvider } from '@openora/core/casino/schema/gaming'; +import { setupTestDb, bootTestApp, seedMinimal, type TestDb, type TestApp } from '../index.js'; + +let db: TestDb; +let app: TestApp; +let playableSlug: string; +let playableId: string; +let inactiveSlug: string; + +// oxlint-disable-next-line typescript/no-explicit-any -- ad-hoc JSON shape assertions in tests +async function readJson(res: Response): Promise { + return res.json(); +} + +function drizzleOf(container: Container) { + return container.get(DRIZZLE).db; +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + app = await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + await seedMinimal(app.container, { playerCount: 0 }); + + const suffix = randomUUID().slice(0, 8); + const [provider] = await drizzleOf(app.container) + .insert(gameProvider) + .values({ + slug: `e2e-by-slug-provider-${suffix}`, + name: 'E2E By Slug Provider', + isActive: true, + }) + .returning(); + if (!provider) { + throw new Error('failed to seed the provider'); + } + playableSlug = `e2e-by-slug-game-${suffix}`; + inactiveSlug = `e2e-by-slug-inactive-${suffix}`; + const [playable] = await drizzleOf(app.container) + .insert(game) + .values([ + { + name: 'E2E By Slug Game', + slug: playableSlug, + providerId: provider.id, + aggregator: 'direct', + isActive: true, + }, + { + name: 'E2E By Slug Inactive', + slug: inactiveSlug, + providerId: provider.id, + aggregator: 'direct', + isActive: false, + }, + ]) + .returning(); + if (!playable) { + throw new Error('failed to seed the games'); + } + playableId = playable.id; +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('gaming getGameBySlug e2e', () => { + it('returns the same game getGame returns for its id', async () => { + const res = await app.app.request(`/gaming/games/by-slug/${playableSlug}`); + expect(res.status).toBe(200); + const bySlug = await readJson(res); + expect(bySlug).toMatchObject({ id: playableId, slug: playableSlug, name: 'E2E By Slug Game' }); + + const byId = await readJson(await app.app.request(`/gaming/games/${playableId}`)); + expect(bySlug).toEqual(byId); + }); + + it('404s an inactive game and an unknown slug, and 400s a malformed one', async () => { + const inactive = await app.app.request(`/gaming/games/by-slug/${inactiveSlug}`); + expect(inactive.status).toBe(404); + + const unknown = await app.app.request(`/gaming/games/by-slug/no-such-game-${randomUUID()}`); + expect(unknown.status).toBe(404); + + const malformed = await app.app.request('/gaming/games/by-slug/Not_A_Slug'); + expect(malformed.status).toBe(400); + }); +});