Files
electric-cherry/tests/unit/afk.test.ts
T
2026-10-17 16:57:26 -04:00

736 lines
28 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { routePermission } from '../../src/lib/server/auth/access.js';
let fixture: Fixture;
const server = 't5_zombie_theater_1';
const other = 't6_zm_transit_1';
let secret: string;
let otherSecret: string;
let instanceId: string;
let hostId: string;
let hostToken: string;
let serial = 0;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([server, other]);
vi.stubEnv('EC_TELEMETRY_TOKEN', 'telemetry_' + 't'.repeat(40));
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
const { bankCredential } = await import('../../src/lib/server/auth/bank.js');
const { createHost, createEnrollment, enrollHost } =
await import('../../src/lib/server/hosts/index.js');
hostId = createHost({ name: 'AFK host' }).id;
hostToken = enrollHost(createEnrollment(hostId).token, {}).credential;
for (const source of [server, other]) {
const id = randomUUID();
db()
.prepare(
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)`
)
.run(id, source, hostId, new Date().toISOString());
if (source === server) {
instanceId = id;
secret = bankCredential(id);
} else otherSecret = bankCredential(id);
saveTelemetry({
server: source,
map: source === server ? 'zombie_theater' : 'zm_transit',
match: 'match-one',
seq: 1,
event: 'game_start',
players: [{ guid: 'ace', name: 'Ace' }],
receivedAt: new Date().toISOString()
});
}
db()
.prepare('UPDATE native_instances SET created_at=?')
.run(new Date(Date.now() - 86460000).toISOString());
// Gameplay snapshots may report up to a day of actual active time in these fixtures.
db()
.prepare('UPDATE leaderboard_matches SET started_at=?')
.run(new Date(Date.now() - 86400000).toISOString());
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
const base = (source = server) => ({
server: source,
guid: 'ace',
match: 'match-one',
request_id: `afk-${++serial}`
});
const event = (extra = {}) => ({
...base(),
seq: 1,
active_seconds: 0,
kills: 0,
headshots: 0,
rounds: 0,
revives: 0,
activity: 'playing',
...extra
});
const reserve = (extra = {}) => ({
...base(),
session_id: `session-${++serial}`,
seconds: 604800,
...extra
});
const start = (session_id: string) => ({ ...base(), session_id });
const settle = (
session_id: string,
consumed_seconds: number | null = 30,
end_reason = 'manual',
extra = {}
) => ({ ...base(), session_id, consumed_seconds, end_reason, ...extra });
async function post(action: string, body: unknown, credential = secret) {
const { POST } = await import('../../src/routes/api/afk/[action]/+server.js');
const response = await POST({
params: { action },
request: new Request(`https://test/api/afk/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${credential}` },
body: JSON.stringify(body)
})
});
expect(response.headers.get('cache-control')).toBe('no-store');
return { status: response.status, ...(await response.json()) };
}
async function fund(points = 100000) {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('INSERT INTO bank_accounts(guid,balance) VALUES (?,?)').run('ace', points);
}
it('authenticates before reading bodies and binds new operations to current membership', async () => {
expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine');
expect(routePermission('/api/afk/[action]', 'GET')).toBe('viewer');
expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin');
const { POST } = await import('../../src/routes/api/afk/[action]/+server.js');
for (const credential of ['', process.env.EC_TELEMETRY_TOKEN!]) {
const request = new Request('https://test/api/afk/event', {
method: 'POST',
headers: { Authorization: `Bearer ${credential}` },
body: 'invalid JSON'
});
expect((await POST({ params: { action: 'event' }, request })).status).toBe(401);
expect(request.bodyUsed).toBe(false);
}
expect(await post('balance', base(), otherSecret)).toMatchObject({
status: 403,
code: 'wrong_server'
});
expect(await post('balance', { ...base(), guid: 'outsider' })).toMatchObject({
code: 'not_in_match'
});
expect(await post('reserve', { ...reserve(), match: 'old' })).toMatchObject({
code: 'not_in_match'
});
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE leaderboard_match_players SET present=0').run();
expect(
await post('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({
code: 'not_in_match'
});
});
it('shares GUID balances across games and rolls free allowance for actual spending without accumulation', async () => {
expect(await post('balance', base())).toMatchObject({ free_seconds: 1800, saved_seconds: 0 });
const session = reserve();
expect(await post('reserve', session)).toMatchObject({
authorized_seconds: 1800,
free_seconds: 0
});
await post('start', start(session.session_id));
expect(await post('settle', settle(session.session_id, 600))).toMatchObject({
free_seconds: 1200,
reserved_seconds: 0
});
expect(await post('balance', base(other), otherSecret)).toMatchObject({ free_seconds: 1200 });
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(await post('balance', base())).toMatchObject({ free_seconds: 1200 });
db().prepare('UPDATE afk_sessions SET free_charge_at=free_charge_at-?').run(86400001);
expect(await post('balance', base())).toMatchObject({ free_seconds: 1800 });
db()
.prepare('UPDATE afk_sessions SET free_charge_at=free_charge_at-?')
.run(10 * 86400000);
expect(await post('balance', base())).toMatchObject({ free_seconds: 1800 });
});
it('never refunds a held free authorization because a day, heartbeat, or process has passed', async () => {
const session = reserve();
await post('reserve', session);
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare('UPDATE afk_accounts SET free_started_at=free_started_at-?')
.run(2 * 86400000);
expect(await post('balance', base(other), otherSecret)).toMatchObject({
free_seconds: 0,
reserved_seconds: 1800
});
expect(await post('reserve', reserve({ server: other }), otherSecret)).toMatchObject({
code: 'session_pending'
});
expect(await post('settle', settle(session.session_id, 100))).toMatchObject({
free_seconds: 1700
});
});
it('retains partial progress across reconnects and games, caps performance, and never caps repeated revives', async () => {
expect(
await post('event', event({ active_seconds: 299, kills: 99, headshots: 24 }))
).toMatchObject({ awarded_seconds: 0 });
expect(
await post(
'event',
event({ server: other, active_seconds: 1, kills: 1, headshots: 1 }),
otherSecret
)
).toMatchObject({ awarded_seconds: 105 });
const big = event({
seq: 2,
active_seconds: 300,
kills: 2000,
headshots: 24,
rounds: 50,
revives: 20
});
expect(await post('event', big)).toMatchObject({ awarded_seconds: 1035 }); // remaining 435 cap + 600 revive
const more = event({
seq: 3,
active_seconds: 300,
kills: 2100,
headshots: 25,
rounds: 51,
revives: 21
});
const first = await post('event', more);
expect(first).toMatchObject({ awarded_seconds: 30 });
expect(await post('event', more)).toEqual(first);
expect(await post('event', { ...more, request_id: `new-${++serial}` })).toMatchObject({
awarded_seconds: 0
});
expect(
await post(
'event',
event({
seq: 4,
active_seconds: 300,
kills: 2100,
headshots: 25,
rounds: 51,
revives: 22
})
)
).toMatchObject({ awarded_seconds: 30 });
expect(
await post(
'event',
event({
seq: 5,
active_seconds: 3900,
kills: 2200,
headshots: 25,
rounds: 52,
revives: 22
})
)
).toMatchObject({ awarded_seconds: 765 }); // 720 active +45 performance
});
it('rejects spectator, AFK, impossible active time and counter regression; stale snapshots never replay progress', async () => {
for (const activity of ['afk', 'spectator', 'connected'])
expect(await post('event', event({ activity }))).toMatchObject({ status: 400 });
const snapshot = event({ seq: 3, active_seconds: 300, kills: 100, revives: 1 });
expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 120 });
expect(await post('event', event({ seq: 2, active_seconds: 200, kills: 50 }))).toMatchObject({
code: 'stale',
awarded_seconds: 0
});
expect(
await post('event', event({ seq: 3, active_seconds: 300, kills: 100, revives: 2 }))
).toMatchObject({ code: 'progress_conflict' });
expect(await post('event', event({ seq: 4, active_seconds: 0, kills: 100 }))).toMatchObject({
code: 'progress_regression'
});
expect(await post('event', event({ seq: 5, active_seconds: 999999 }))).toMatchObject({
code: 'invalid_active_time'
});
const session = reserve();
await post('reserve', session);
expect(
await post('event', event({ seq: 6, active_seconds: 300, kills: 200, revives: 2 }))
).toMatchObject({ code: 'session_pending' });
});
it('atomically purchases from the existing bank, preserves receipts, and protects pending bank transfers', async () => {
await fund();
const purchase = {
...base(),
packs: 2,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
};
const first = await post('purchase', purchase);
expect(first).toMatchObject({
state: 'purchased',
saved_seconds: 1200,
bank_balance: 80000,
max_purchase_packs: 8
});
expect(await post('purchase', purchase)).toEqual(first);
expect(await post('purchase', { ...purchase, packs: 1 })).toMatchObject({
status: 409,
code: 'request_conflict'
});
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT amount FROM bank_ledger').all()).toEqual([{ amount: -20000 }]);
const { bankRequest } = await import('../../src/lib/server/fleet/bank.js');
bankRequest(
'deposit',
{ ...base(), operation: 'deposit', amount: 1000, capacity: 1000000 },
server,
instanceId
);
expect(
await post('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({
code: 'bank_pending'
});
});
it('rejects invalid, unaffordable and overcapacity purchases and rolls both ledgers back together', async () => {
await fund(10000);
for (const packs of [0, -1, 1.5, '1', 1001])
expect(
await post('purchase', {
...base(),
packs,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({
status: 400
});
expect(
await post('purchase', {
...base(),
packs: 1,
confirmed: false,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({
status: 400
});
expect(
await post('purchase', {
...base(),
packs: 2,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({
code: 'insufficient_balance'
});
const { db } = await import('../../src/lib/server/db/index.js');
db().exec(
"CREATE TRIGGER reject_afk BEFORE INSERT ON afk_ledger WHEN NEW.kind='purchase' BEGIN SELECT RAISE(ABORT,'test'); END"
);
const purchase = {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
};
expect(await post('purchase', purchase)).toMatchObject({ status: 503 });
expect(db().prepare('SELECT balance FROM bank_accounts').get()).toEqual({ balance: 10000 });
expect(db().prepare('SELECT * FROM bank_ledger').all()).toHaveLength(0);
db().exec('DROP TRIGGER reject_afk');
expect(await post('purchase', purchase)).toMatchObject({ saved_seconds: 600, bank_balance: 0 });
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '600');
expect(await post('event', event({ revives: 100 }))).toMatchObject({
saved_seconds: 600,
awarded_seconds: 0
});
});
it('serializes purchases and reservations across servers, counting held saved reserve toward capacity', async () => {
await fund(10000);
const purchases = await Promise.all([
post('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
}),
post(
'purchase',
{
...base(other),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
},
otherSecret
)
]);
expect(purchases.filter((x) => x.state === 'purchased')).toHaveLength(1);
const a = reserve(),
b = reserve({ server: other });
const reservations = await Promise.all([post('reserve', a), post('reserve', b, otherSecret)]);
expect(reservations.filter((x) => x.state === 'reserved')).toHaveLength(1);
expect(reservations.filter((x) => x.code === 'session_pending')).toHaveLength(1);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE bank_accounts SET balance=10000').run();
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '600');
expect(
await post('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({
code: 'reserve_capacity'
});
const selected = reservations[0].state === 'reserved' ? a : b;
expect(
await post(
'settle',
{ ...settle(selected.session_id, 1800), server: selected.server },
selected.server === server ? secret : otherSecret
)
).toMatchObject({ saved_seconds: 600, free_seconds: 0 });
});
it('settles actual seconds after disconnect/deletion and never reopens completed or pre-cancelled sessions', async () => {
const session = reserve();
await post('reserve', session);
await post('start', start(session.session_id));
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().prepare('DELETE FROM leaderboard_matches').run();
db().prepare('DELETE FROM servers').run();
closeDb();
const stop = settle(session.session_id, 45, 'disconnect');
const first = await post('settle', stop);
expect(first).toMatchObject({ state: 'settled', free_seconds: 1755, consumed_seconds: 45 });
expect(await post('settle', stop)).toEqual(first);
expect(await post('start', start(session.session_id))).toMatchObject({ state: 'settled' });
const early = reserve();
expect(await post('settle', settle(early.session_id, 0, 'never_started'))).toMatchObject({
state: 'settled'
});
expect(await post('reserve', early)).toMatchObject({ state: 'settled', authorized_seconds: 0 });
const { afkProfile } = await import('../../src/lib/server/fleet/afk.js');
expect(afkProfile('ace')).toMatchObject({ confirmedAfkSeconds: 45, reservedSeconds: 0 });
expect(afkProfile('ace').entries[0]).toMatchObject({ matchAvailable: 0 });
});
it('records unresolved crashes without invented durations and allows only explicit stopped-session corrections', async () => {
const session = reserve();
await post('reserve', session);
await post('start', start(session.session_id));
const crash = settle(session.session_id, null, 'crash', { confirmed_seconds: 10 });
expect(await post('settle', crash)).toMatchObject({
state: 'unresolved',
free_seconds: 0,
reserved_seconds: 1800,
consumed_seconds: null
});
expect(await post('event', event({ revives: 1 }))).toMatchObject({ awarded_seconds: 30 });
const { afkCorrectSession, afkProfile } = await import('../../src/lib/server/fleet/afk.js');
expect(afkProfile('ace')).toMatchObject({ confirmedAfkSeconds: 0, unresolvedSessions: 1 });
const correction = {
session_id: session.session_id,
request_id: 'correction-one',
consumed_seconds: 30,
reason: 'Verified stopped process; approved accounting adjustment.',
stopped: true
};
expect(afkCorrectSession({ ...correction, consumed_seconds: 9 }, 'admin').body.code).toBe(
'invalid_consumption'
);
expect(afkCorrectSession({ ...correction, stopped: false }, 'admin').status).toBe(400);
const first = afkCorrectSession(correction, 'admin');
expect(first.body).toMatchObject({
state: 'corrected',
free_seconds: 1770,
reserved_seconds: 0,
consumed_seconds: null
});
expect(afkCorrectSession(correction, 'admin')).toEqual(first);
expect(afkCorrectSession({ ...correction, consumed_seconds: 31 }, 'admin').status).toBe(409);
expect(afkProfile('ace').sessions[0]).toMatchObject({
state: 'corrected',
consumedSeconds: null,
chargedSeconds: 30,
confirmedSeconds: 10
});
expect(afkProfile('ace').entries.some((x) => x.kind === 'correction')).toBe(true);
expect(await post('start', start(session.session_id))).toMatchObject({ state: 'corrected' });
const next = reserve();
await post('reserve', next);
expect(
afkCorrectSession(
{ ...correction, request_id: 'other', session_id: next.session_id },
'admin'
).body.code
).toBe('not_unresolved');
});
it('requires full authorized consumption for expiration and forbids rewinding confirmed checkpoints', async () => {
const session = reserve();
await post('reserve', session);
expect(await post('settle', settle(session.session_id, 1799, 'expired'))).toMatchObject({
code: 'invalid_consumption'
});
await post('settle', settle(session.session_id, null, 'crash', { confirmed_seconds: 20 }));
expect(await post('settle', settle(session.session_id, 19))).toMatchObject({
code: 'invalid_consumption'
});
expect(await post('settle', settle(session.session_id, 1800, 'expired'))).toMatchObject({
state: 'settled',
free_seconds: 0,
reserved_seconds: 0
});
const { afkProfile } = await import('../../src/lib/server/fleet/afk.js');
expect(afkProfile('ace').expiredSessions).toBe(1);
});
it('authorizes assigned unfinished teardown recovery and only reads preexisting purchase/event receipts', async () => {
await fund();
const purchase = {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
};
const bought = await post('purchase', purchase);
const session = reserve();
await post('reserve', session);
const { db } = await import('../../src/lib/server/db/index.js');
const { queueHostOperation, createHost, createEnrollment, enrollHost } =
await import('../../src/lib/server/hosts/index.js');
const removal = queueHostOperation(
hostId,
'instance.remove',
{},
{ instanceId, generation: 1 }
);
db()
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
.run(new Date().toISOString(), instanceId);
const { POST } = await import('../../src/routes/api/agent/afk/settle/+server.js');
const recover = (
action = 'settle',
transfer: unknown = settle(session.session_id, 0, 'never_started'),
credential = hostToken,
operationId = removal.id
) =>
POST({
request: new Request('https://test/api/agent/afk/settle', {
method: 'POST',
headers: { Authorization: `Bearer ${credential}` },
body: JSON.stringify({ operationId, action, transfer })
})
});
await expect(recover('settle', undefined, secret)).rejects.toMatchObject({ status: 401 });
const stranger = createHost({ name: 'Other host' });
const strangerToken = enrollHost(createEnrollment(stranger.id).token, {}).credential;
await expect(recover('settle', undefined, strangerToken)).rejects.toMatchObject({
status: 403
});
await expect(recover('start')).rejects.toMatchObject({ status: 400 });
expect(await (await recover('purchase', purchase)).json()).toMatchObject({
state: 'purchased',
bank_balance: bought.bank_balance
});
expect(
await (
await recover('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).json()
).toMatchObject({ code: 'not_applied' });
expect(await (await recover()).json()).toMatchObject({ state: 'settled' });
db().prepare("UPDATE ec_host_operations SET state='completed' WHERE id=?").run(removal.id);
await expect(recover()).rejects.toMatchObject({ status: 403 });
});
it('requires an administrator for accounting corrections', async () => {
const { POST } = await import('../../src/routes/api/afk/corrections/+server.js');
for (const role of ['viewer', 'operator', 'moderator', 'super_viewer']) {
await expect(
POST({
request: new Request('https://test/api/afk/corrections', {
method: 'POST',
body: '{}'
}),
locals: { user: { id: 'someone', role } } as App.Locals
})
).rejects.toMatchObject({ status: 403 });
}
});
it('recovers verified gameplay after match completion while terminally rejecting late purchases and missing history', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const snapshot = event({ active_seconds: 300, revives: 2 });
db()
.prepare("UPDATE leaderboard_matches SET status='completed',ended_at=last_received_at")
.run();
db().prepare('UPDATE leaderboard_match_players SET present=0').run();
expect(await post('event', snapshot)).toMatchObject({
status: 200,
state: 'rewarded',
awarded_seconds: 120
});
expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 120 });
expect(
await post('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
})
).toMatchObject({ status: 200, code: 'not_in_match' });
db().prepare('DELETE FROM leaderboard_matches').run();
expect(await post('event', event({ seq: 2, active_seconds: 600, revives: 3 }))).toMatchObject({
status: 200,
code: 'not_in_match'
});
});
it('rejects stale purchase quotes without spending and retries initial membership publication lag', async () => {
await fund();
expect(
await post('purchase', {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 1,
expected_purchase_seconds: 600
})
).toMatchObject({ code: 'price_changed', bank_balance: 100000 });
const { db } = await import('../../src/lib/server/db/index.js');
const pending = reserve();
db()
.prepare("UPDATE leaderboard_match_players SET guid='not-yet-published' WHERE guid='ace'")
.run();
expect(await post('reserve', pending)).toMatchObject({ status: 409, code: 'not_in_match' });
db()
.prepare("UPDATE leaderboard_match_players SET guid='ace' WHERE guid='not-yet-published'")
.run();
expect(await post('reserve', pending)).toMatchObject({ state: 'reserved' });
});
it('binds reservation receipts to immutable instance/session owners and ignores raw score changes', async () => {
const snapshot = event({ score: 999999, score_total: 999999 });
expect(await post('event', snapshot)).toMatchObject({ awarded_seconds: 0 });
const session = reserve();
const first = await post('reserve', session);
expect(await post('reserve', session)).toEqual(first);
expect(await post('reserve', { ...session, seconds: 1 })).toMatchObject({
status: 409,
code: 'request_conflict'
});
expect(
await post('settle', { ...settle(session.session_id), server: other }, otherSecret)
).toMatchObject({ status: 409, code: 'session_conflict' });
const { db } = await import('../../src/lib/server/db/index.js');
const { bankCredential } = await import('../../src/lib/server/auth/bank.js');
db()
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
.run(new Date().toISOString(), instanceId);
expect(await post('balance', base())).toMatchObject({ status: 401 });
const replacement = randomUUID();
db()
.prepare(
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES (?,?,?,'library','image',28960,?)`
)
.run(replacement, server, hostId, new Date().toISOString());
const replacementSecret = bankCredential(replacement);
expect(await post('reserve', session, replacementSecret)).toMatchObject({
status: 409,
code: 'request_conflict'
});
expect(await post('settle', settle(session.session_id), replacementSecret)).toMatchObject({
status: 409,
code: 'session_conflict'
});
});
it('terminally rejects departed-player new work without blocking historical contribution recovery', async () => {
await fund();
const session = reserve();
await post('reserve', session);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE leaderboard_match_players SET present=0').run();
const purchase = {
...base(),
packs: 1,
confirmed: true,
expected_purchase_points: 10000,
expected_purchase_seconds: 600
};
for (const [action, body] of [
['balance', base()],
['reserve', reserve()],
['start', start(session.session_id)],
['purchase', purchase]
] as const) {
expect(await post(action, body)).toMatchObject({
status: 200,
state: 'rejected',
code: 'not_in_match'
});
}
expect(await post('event', event({ active_seconds: 300, revives: 1 }))).toMatchObject({
status: 200,
state: 'rewarded',
awarded_seconds: 90
});
db().prepare('UPDATE leaderboard_match_players SET present=1').run();
expect(await post('purchase', purchase)).toMatchObject({
status: 200,
state: 'rejected',
code: 'not_in_match'
});
expect(db().prepare('SELECT balance FROM bank_accounts WHERE guid=?').get('ace')).toEqual({
balance: 100000
});
});