mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
Until now every finished run said only `ended` or `failed`. A user stop, a trial window running out, an idle timeout, a host restart and a game quitting were indistinguishable afterwards. **Schema.** Migration `0026_session_end_reason` adds two nullable columns to `session`: - `end_reason`: one of `user`, `window`, `hours`, `idle`, `left_queue`, `game_exited`, `launch_failed`, `host_stopped`, `host_restart`. - `time_claimed`: when a host took the run, set on `requested → starting`. **API.** - `POST /session/:id/state` takes an optional `endReason`. With `ended` or `failed` it is stored. With `starting` or `live` it is a 400 and the run does not move. - Leaving it out of a terminal report stores null, so host agents that predate this keep working. - `endReason` and `timeClaimed` are returned on every session response. - The API writes the reasons it decides itself: `user` when a run nobody has claimed is stopped, and `left_queue` when a waiting run is abandoned (the `Left the queue` message is kept). **New transition, `starting → ended`.** A run stopped while its game is still downloading has failed at nothing, and never reached `live`, which is where billing starts. Until now the only way out of `starting` was `failed`. **Release order: this goes first.** The host agent that sends `endReason` is a separate change. This route's body schema is strict, so without this deployed the current API answers that agent with a 400. **Tests.** - API: 185 pass, 5 of them new. - Core: 41 session tests pass, 3 of them new. - The new tests fail when the implementation is broken on purpose. - No new type errors.
163 lines
5.7 KiB
TypeScript
163 lines
5.7 KiB
TypeScript
import { afterAll, describe, expect, test } from 'bun:test';
|
|
|
|
import { AccessToken } from '@nestri/core/access-token/index';
|
|
import { Box } from '@nestri/core/box/index';
|
|
import { Fixtures } from '@nestri/core/db/fixtures';
|
|
import { testDb } from '@nestri/core/db/test';
|
|
import { Game } from '@nestri/core/game/index';
|
|
import { Identifier } from '@nestri/core/id';
|
|
import { Machine } from '@nestri/core/machine/index';
|
|
import { Session } from '@nestri/core/session/index';
|
|
|
|
import { app } from '../app/index';
|
|
|
|
const sql = testDb();
|
|
const createdUserIds: string[] = [];
|
|
const createdGameIds: string[] = [];
|
|
|
|
afterAll(async () => {
|
|
await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`;
|
|
await sql`delete from box where user_id = any(${createdUserIds})`;
|
|
await sql`delete from machine where owner_user_id = any(${createdUserIds})`;
|
|
await sql`delete from game where id = any(${createdGameIds})`;
|
|
await sql`delete from "user" where id = any(${createdUserIds})`;
|
|
});
|
|
|
|
/** One host, and a way to put a player in line on it. */
|
|
async function host(label: string, steamAppId: number) {
|
|
const operator = await Fixtures.owner(`${label}-host`);
|
|
createdUserIds.push(operator.userId);
|
|
const machine = await Machine.register({
|
|
id: Identifier.ascending('machine'),
|
|
ownerUserId: operator.userId,
|
|
teamId: operator.teamId,
|
|
label
|
|
});
|
|
const [game] = await Game.upsert({
|
|
id: Identifier.ascending('game'),
|
|
steamAppId,
|
|
slug: `queue-${steamAppId}`,
|
|
name: `Queue ${steamAppId}`
|
|
});
|
|
createdGameIds.push(game!.id);
|
|
const headers = {
|
|
'x-nestri-machine-id': machine.id,
|
|
'x-nestri-machine-secret': machine.secret,
|
|
'content-type': 'application/json'
|
|
};
|
|
|
|
async function player(name: string, paid: boolean) {
|
|
const owner = await Fixtures.owner(`${label}-${name}`);
|
|
createdUserIds.push(owner.userId);
|
|
const box = await Box.create({
|
|
id: Identifier.ascending('box'),
|
|
userId: owner.userId,
|
|
machineId: machine.id,
|
|
label: name,
|
|
tier: 'sm'
|
|
});
|
|
const run = await Session.request({
|
|
id: Identifier.ascending('session'),
|
|
boxId: box.id,
|
|
gameId: game!.id,
|
|
linkedAccountId: owner.linkedAccountId,
|
|
trial: !paid
|
|
});
|
|
const pat = await AccessToken.create({
|
|
id: Identifier.ascending('accessToken'),
|
|
ownerUserId: owner.userId,
|
|
teamId: null,
|
|
name
|
|
});
|
|
const read = async () => {
|
|
const res = await app.request(`/session/${run.id}`, {
|
|
headers: { authorization: `Bearer ${pat.token}` }
|
|
});
|
|
return ((await res.json()) as any).data;
|
|
};
|
|
return { run, read };
|
|
}
|
|
|
|
const beat = (slots: number) =>
|
|
app.request('/machine/heartbeat', {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ slots })
|
|
});
|
|
const jobs = async () => {
|
|
const res = await app.request('/machine/jobs', { headers });
|
|
return (((await res.json()) as any).data as any[]).filter((j) => j.kind === 'session.start');
|
|
};
|
|
return { machine, player, beat, jobs };
|
|
}
|
|
|
|
describe('the line for a full host', () => {
|
|
test('a host is handed only as many runs as it has free slots', async () => {
|
|
const h = await host('queue-slots', 7701);
|
|
const a = await h.player('a', true);
|
|
const b = await h.player('b', true);
|
|
const c = await h.player('c', true);
|
|
|
|
// An agent that never said how many it holds gets everything, as before.
|
|
expect(await h.jobs()).toHaveLength(3);
|
|
|
|
expect((await h.beat(2)).status).toBe(200);
|
|
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([a.run.id, b.run.id]);
|
|
|
|
await Session.setState({ id: a.run.id, state: 'live' });
|
|
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([b.run.id]);
|
|
await Session.setState({ id: b.run.id, state: 'live' });
|
|
expect(await h.jobs()).toHaveLength(0);
|
|
expect((await c.read()).queue).toEqual({ position: 1, paidFirst: false });
|
|
|
|
// A slot frees and the next in line is handed it.
|
|
await Session.setState({ id: a.run.id, state: 'ended' });
|
|
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([c.run.id]);
|
|
});
|
|
|
|
test('paid runs go first, and a free player is told so', async () => {
|
|
const h = await host('queue-paid', 7702);
|
|
await h.beat(1);
|
|
const playing = await h.player('playing', true);
|
|
await Session.setState({ id: playing.run.id, state: 'live' });
|
|
|
|
const free = await h.player('free', false);
|
|
const paid = await h.player('paid', true);
|
|
|
|
expect(await free.read()).toMatchObject({ queue: { position: 2, paidFirst: true } });
|
|
expect(await paid.read()).toMatchObject({ queue: { position: 1, paidFirst: false } });
|
|
|
|
await Session.setState({ id: playing.run.id, state: 'ended' });
|
|
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([paid.run.id]);
|
|
});
|
|
|
|
test('a run nobody asks after leaves the line', async () => {
|
|
const h = await host('queue-gone', 7703);
|
|
await h.beat(1);
|
|
const playing = await h.player('playing', true);
|
|
await Session.setState({ id: playing.run.id, state: 'live' });
|
|
const gone = await h.player('gone', true);
|
|
const here = await h.player('here', true);
|
|
|
|
await sql`update session set time_created = now() - interval '6 minutes' where id = ${gone.run.id}`;
|
|
await sql`update session set time_created = now() - interval '5 minutes' where id = ${here.run.id}`;
|
|
expect((await here.read()).queue.position).toBe(2);
|
|
|
|
await Session.setState({ id: playing.run.id, state: 'ended' });
|
|
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]);
|
|
const left = await Session.fromID(gone.run.id);
|
|
expect(left).toMatchObject({
|
|
state: 'ended',
|
|
errorMessage: 'Left the queue',
|
|
endReason: 'left_queue'
|
|
});
|
|
});
|
|
|
|
test('a run with a slot is not in line', async () => {
|
|
const h = await host('queue-none', 7704);
|
|
const a = await h.player('a', true);
|
|
await Session.setState({ id: a.run.id, state: 'live' });
|
|
expect((await a.read()).queue).toBeNull();
|
|
});
|
|
});
|