mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
test(commands): cover fun commands, cooldowns, and the dispatcher permission gate
89 new node:test cases. The dispatcher suite is the important one: it pins the behavior the registry-driven permission gate replaced a hardcoded action list with, asserting that admin-only commands are still admin-only, that the self-policing commands (goal/reason/verify/deter) still reach their own handlers as a non-admin, that unknown actions are still not public, and that `rsvp` is still not a command. Also covered: - cooldown boundaries, including that a refused call does not extend the window - actor identity keying across transports, and that extra browser tabs do not make a target ambiguous - honk/spin refusing without drive control and being unreachable from Discord - spin honouring applyPrivateDriveSafety instead of bypassing it - boo speaking only canned text, never anything the caller typed - disco obeying the room-light lock and restoring the lights when it ends - mention sanitizing on replies and on stored nicknames rendered by bonkboard - the stats store degrading to empty on a corrupt or wrong-shaped file Full server suite: 126 passing, 0 failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a2fbbc100e
commit
a9428d3d72
@@ -0,0 +1,131 @@
|
||||
// Fun Stats Service Tests
|
||||
// Purpose: Verifies counter persistence, clamping, and that a corrupt store degrades instead of throwing.
|
||||
// Scope: Runs against a temporary SERVER_DATA_DIR so the real data directory is never touched.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fun-stats-test-'));
|
||||
process.env.SERVER_DATA_DIR = dataDir;
|
||||
|
||||
const funStatsService = require('./index');
|
||||
|
||||
function reset() {
|
||||
try {
|
||||
fs.rmSync(funStatsService.STORE_PATH, { force: true });
|
||||
} catch {
|
||||
// A missing store is the normal starting state.
|
||||
}
|
||||
funStatsService.resetCacheForTests();
|
||||
}
|
||||
|
||||
test('counters start at zero for an unknown actor', () => {
|
||||
reset();
|
||||
const stats = funStatsService.getActorStats('user:nobody');
|
||||
assert.equal(stats.bonksGiven, 0);
|
||||
assert.equal(stats.bonksTaken, 0);
|
||||
assert.equal(stats.label, null);
|
||||
});
|
||||
|
||||
test('bumping a counter accumulates and records the label', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
|
||||
const stats = funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
|
||||
assert.equal(stats.bonksGiven, 2);
|
||||
assert.equal(stats.label, 'alice');
|
||||
});
|
||||
|
||||
test('counters are independent of one another', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('user:alice', { bonksGiven: 3, hugsGiven: 1 });
|
||||
const stats = funStatsService.getActorStats('user:alice');
|
||||
assert.equal(stats.bonksGiven, 3);
|
||||
assert.equal(stats.hugsGiven, 1);
|
||||
assert.equal(stats.slapsGiven, 0);
|
||||
});
|
||||
|
||||
test('an unrecognized counter name is ignored rather than silently stored', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('user:alice', { notACounter: 5 });
|
||||
const stats = funStatsService.getActorStats('user:alice');
|
||||
assert.equal(stats.notACounter, undefined);
|
||||
});
|
||||
|
||||
test('state survives a cold read from disk', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 7 });
|
||||
funStatsService.resetCacheForTests();
|
||||
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 7);
|
||||
});
|
||||
|
||||
test('an empty actor key is refused so anonymous bumps cannot share a bucket', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('', { bonksGiven: 1 });
|
||||
assert.deepEqual(funStatsService.listActorStats(), []);
|
||||
});
|
||||
|
||||
test('rover pets accumulate per rover', () => {
|
||||
reset();
|
||||
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 1);
|
||||
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 2);
|
||||
assert.equal(funStatsService.bumpRoverPets('rover-2', 1), 1);
|
||||
assert.equal(funStatsService.getRoverPets('rover-1'), 2);
|
||||
assert.equal(funStatsService.getRoverPets('unknown'), 0);
|
||||
});
|
||||
|
||||
test('listActorStats returns every actor with their key', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
|
||||
funStatsService.bumpActorStats('discord:4242', { label: 'dave', bonksGiven: 2 });
|
||||
const keys = funStatsService.listActorStats().map((row) => row.actorKey).sort();
|
||||
assert.deepEqual(keys, ['discord:4242', 'user:alice']);
|
||||
});
|
||||
|
||||
test('negative and non-numeric deltas cannot drive a counter below zero', () => {
|
||||
reset();
|
||||
funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 });
|
||||
funStatsService.bumpActorStats('user:alice', { bonksGiven: -50 });
|
||||
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
|
||||
|
||||
funStatsService.bumpActorStats('user:alice', { bonksGiven: Number.NaN });
|
||||
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
|
||||
});
|
||||
|
||||
test('labels are trimmed and length capped', () => {
|
||||
reset();
|
||||
const stats = funStatsService.bumpActorStats('user:alice', { label: ` ${'x'.repeat(200)} `, bonksGiven: 1 });
|
||||
assert.equal(stats.label.length, 64);
|
||||
});
|
||||
|
||||
test('a corrupt store file degrades to empty instead of throwing', () => {
|
||||
reset();
|
||||
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(funStatsService.STORE_PATH, '{not json at all', 'utf8');
|
||||
funStatsService.resetCacheForTests();
|
||||
|
||||
assert.deepEqual(funStatsService.listActorStats(), []);
|
||||
// And it must still be writable afterwards.
|
||||
assert.equal(funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }).bonksGiven, 1);
|
||||
});
|
||||
|
||||
test('a store with the wrong shape is normalized rather than trusted', () => {
|
||||
reset();
|
||||
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
funStatsService.STORE_PATH,
|
||||
JSON.stringify({ actors: { 'user:alice': { bonksGiven: 'lots', label: 42 } }, rovers: 'nope' }),
|
||||
'utf8',
|
||||
);
|
||||
funStatsService.resetCacheForTests();
|
||||
|
||||
const stats = funStatsService.getActorStats('user:alice');
|
||||
assert.equal(stats.bonksGiven, 0);
|
||||
assert.equal(stats.label, '42');
|
||||
assert.equal(funStatsService.getRoverPets('rover-1'), 0);
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// Operator Fun Helper Tests
|
||||
// Purpose: Locks down actor identity, target resolution, and the deterministic seeding the fun commands depend on.
|
||||
// Scope: Pure helpers plus in-memory socket doubles; nothing here touches the fun stats store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
buildActorKey,
|
||||
clampEcho,
|
||||
createRoverResolver,
|
||||
hashSeed,
|
||||
ordinal,
|
||||
pairSeed,
|
||||
percentFromSeed,
|
||||
pickBySeed,
|
||||
resolveFunTarget,
|
||||
MAX_ECHO_LENGTH,
|
||||
} = require('./funHelpers');
|
||||
|
||||
function socket(id, userId, nickname) {
|
||||
return { id, data: { userId, nickname } };
|
||||
}
|
||||
|
||||
function harness(sockets = []) {
|
||||
return {
|
||||
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
|
||||
getNickname: (entry) => entry?.data?.nickname || '',
|
||||
};
|
||||
}
|
||||
|
||||
test('site chat keys on the identity user id, not the socket', () => {
|
||||
assert.equal(
|
||||
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1', userId: 'u-alice' } }),
|
||||
'user:u-alice',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unidentified site socket falls back to its socket id', () => {
|
||||
assert.equal(
|
||||
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1' } }),
|
||||
'socket:socket-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('discord actors get their own key space so ids cannot collide with identity ids', () => {
|
||||
assert.equal(buildActorKey({ transport: 'discord', actor: { id: '4242' } }), 'discord:4242');
|
||||
});
|
||||
|
||||
test('an actor with no usable id at all is rejected rather than sharing a bucket', () => {
|
||||
assert.equal(buildActorKey({ transport: 'web-chat', actor: {} }), null);
|
||||
assert.equal(buildActorKey({ transport: 'discord', actor: {} }), null);
|
||||
});
|
||||
|
||||
test('a single online nickname resolves to that user and credits their tally', () => {
|
||||
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
|
||||
const resolved = resolveFunTarget({ io, getNickname, selector: 'BOB' });
|
||||
assert.equal(resolved.label, 'bob');
|
||||
assert.equal(resolved.actorKey, 'user:u-bob');
|
||||
assert.equal(resolved.online, true);
|
||||
});
|
||||
|
||||
test('multiple tabs for one person do not make the target ambiguous', () => {
|
||||
const { io, getNickname } = harness([
|
||||
socket('s1', 'u-bob', 'bob'),
|
||||
socket('s2', 'u-bob', 'bob'),
|
||||
]);
|
||||
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
|
||||
assert.equal(resolved.actorKey, 'user:u-bob');
|
||||
});
|
||||
|
||||
test('an unmatched selector still works but credits nobody', () => {
|
||||
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
|
||||
const resolved = resolveFunTarget({ io, getNickname, selector: 'the dishwasher' });
|
||||
assert.equal(resolved.label, 'the dishwasher');
|
||||
assert.equal(resolved.actorKey, null);
|
||||
assert.equal(resolved.online, false);
|
||||
});
|
||||
|
||||
test('two different people sharing a nickname credit neither', () => {
|
||||
const { io, getNickname } = harness([
|
||||
socket('s1', 'u-bob', 'bob'),
|
||||
socket('s2', 'u-other', 'bob'),
|
||||
]);
|
||||
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
|
||||
assert.equal(resolved.actorKey, null);
|
||||
});
|
||||
|
||||
test('echoed text is length capped so a fun command cannot shout a wall of text', () => {
|
||||
const long = 'a'.repeat(500);
|
||||
const clamped = clampEcho(long);
|
||||
assert.equal(clamped.length, MAX_ECHO_LENGTH);
|
||||
assert.ok(clamped.endsWith('…'));
|
||||
});
|
||||
|
||||
test('ship is order independent so both spellings agree', () => {
|
||||
assert.equal(pairSeed('alice', 'bob'), pairSeed('bob', 'alice'));
|
||||
});
|
||||
|
||||
test('seeded verdicts are stable, so a rating cannot be rerolled by asking again', () => {
|
||||
const first = percentFromSeed(pairSeed('alice', 'bob'));
|
||||
const second = percentFromSeed(pairSeed('alice', 'bob'));
|
||||
assert.equal(first, second);
|
||||
assert.ok(first >= 0 && first <= 100);
|
||||
});
|
||||
|
||||
test('hashSeed ignores case and surrounding whitespace', () => {
|
||||
assert.equal(hashSeed(' Will It Dock '), hashSeed('will it dock'));
|
||||
});
|
||||
|
||||
test('pickBySeed stays in range and tolerates an empty list', () => {
|
||||
const list = ['a', 'b', 'c'];
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
assert.ok(list.includes(pickBySeed(list, seed)));
|
||||
}
|
||||
assert.equal(pickBySeed([], 5), null);
|
||||
});
|
||||
|
||||
test('ordinal handles the teens correctly', () => {
|
||||
assert.equal(ordinal(1), '1st');
|
||||
assert.equal(ordinal(2), '2nd');
|
||||
assert.equal(ordinal(3), '3rd');
|
||||
assert.equal(ordinal(4), '4th');
|
||||
assert.equal(ordinal(11), '11th');
|
||||
assert.equal(ordinal(12), '12th');
|
||||
assert.equal(ordinal(13), '13th');
|
||||
assert.equal(ordinal(21), '21st');
|
||||
assert.equal(ordinal(111), '111th');
|
||||
});
|
||||
|
||||
test('an explicit rover name wins over whatever the caller is attached to', () => {
|
||||
const rovers = new Map([
|
||||
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
|
||||
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
|
||||
]);
|
||||
const resolve = createRoverResolver({
|
||||
rovers,
|
||||
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
|
||||
getActorSocket: () => ({ id: 's1' }),
|
||||
});
|
||||
assert.equal(resolve('Roomba Two').id, 'rover-2');
|
||||
});
|
||||
|
||||
test('with no rover named the caller\'s current rover is used', () => {
|
||||
const rovers = new Map([['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }]]);
|
||||
const resolve = createRoverResolver({
|
||||
rovers,
|
||||
roverManager: { getPrimaryRoverForSocket: (socketId) => (socketId === 's1' ? 'rover-1' : null) },
|
||||
getActorSocket: () => ({ id: 's1' }),
|
||||
});
|
||||
const resolved = resolve('');
|
||||
assert.equal(resolved.id, 'rover-1');
|
||||
assert.equal(resolved.name, 'Roomba One');
|
||||
});
|
||||
|
||||
test('without a socket the caller is asked to name a rover instead of one being chosen', () => {
|
||||
const resolve = createRoverResolver({
|
||||
rovers: new Map(),
|
||||
roverManager: {},
|
||||
getActorSocket: () => null,
|
||||
commandPrefix: 'rs',
|
||||
});
|
||||
assert.match(resolve('', 'pet').error, /Name a rover/);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
// Operator Fun Rover Command Tests
|
||||
// Purpose: Verifies the control, feature, and lock checks the hardware-backed fun commands must make themselves.
|
||||
// Scope: issueCommand, roverManager, and Home Assistant are all doubles; no real rover or timer is involved.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createFunRoverCommands, describeBattery } = require('./funRover');
|
||||
const { createCooldownGate } = require('../cooldowns');
|
||||
|
||||
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
|
||||
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
|
||||
|
||||
function createHarness({
|
||||
canDrive = true,
|
||||
socket = ALICE,
|
||||
hornEnabled = true,
|
||||
ttsEnabled = true,
|
||||
online = true,
|
||||
maxWheelSpeed = 300,
|
||||
privateSafetyDrive = null,
|
||||
activeDrivers = {},
|
||||
homeAssistantService = null,
|
||||
featureEnabled = false,
|
||||
sockets = [ALICE, BOB],
|
||||
} = {}) {
|
||||
const issued = [];
|
||||
const record = {
|
||||
id: 'rover-1',
|
||||
ws: online ? {} : null,
|
||||
locked: false,
|
||||
meta: {
|
||||
name: 'Roomba One',
|
||||
maxWheelSpeed,
|
||||
horn: { enabled: hornEnabled },
|
||||
audio: { ttsEnabled },
|
||||
},
|
||||
batteryState: { percentDisplay: 74, warnActive: false, urgentActive: false },
|
||||
};
|
||||
const rovers = new Map([['rover-1', record]]);
|
||||
|
||||
const handlers = createFunRoverCommands({
|
||||
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
|
||||
rovers,
|
||||
roverManager: {
|
||||
canDrive: () => canDrive,
|
||||
getPrimaryRoverForSocket: () => 'rover-1',
|
||||
applyPrivateDriveSafety: () => privateSafetyDrive,
|
||||
},
|
||||
getNickname: (entry) => entry?.data?.nickname || '',
|
||||
getActiveDrivers: () => activeDrivers,
|
||||
getActorSocket: () => socket,
|
||||
issueCommand: (roverId, payload) => {
|
||||
if (!record.ws) throw new Error('Rover offline');
|
||||
issued.push({ roverId, ...payload });
|
||||
return 'cmd-1';
|
||||
},
|
||||
homeAssistantService,
|
||||
isFeatureEnabled: () => featureEnabled,
|
||||
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
|
||||
cooldowns: createCooldownGate(),
|
||||
logger: { warn: () => {} },
|
||||
config: { commands: { prefix: 'rs' } },
|
||||
});
|
||||
|
||||
return { handlers, issued, record, rovers };
|
||||
}
|
||||
|
||||
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
|
||||
const replies = [];
|
||||
return {
|
||||
transport: 'web-chat',
|
||||
actor,
|
||||
replies,
|
||||
reply: async (payload) => {
|
||||
replies.push(payload);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('honk starts the horn and schedules a stop', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { handlers, issued } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.honk(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /HONK/);
|
||||
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start']);
|
||||
|
||||
// The stop is deferred, so nothing has released the horn yet.
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start', 'stop']);
|
||||
});
|
||||
|
||||
test('honk is refused without drive control', async () => {
|
||||
const { handlers, issued } = createHarness({ canDrive: false });
|
||||
const msg = message();
|
||||
await handlers.honk(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /need control of Roomba One/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('honk is refused from a transport with no socket, so Discord cannot drive hardware', async () => {
|
||||
const { handlers, issued } = createHarness({ socket: null });
|
||||
const msg = message({ id: '4242', label: 'DiscordUser' });
|
||||
await handlers.honk(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /only works from site chat/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('honk is refused on a rover with no horn fitted', async () => {
|
||||
const { handlers, issued } = createHarness({ hornEnabled: false });
|
||||
const msg = message();
|
||||
await handlers.honk(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /no horn fitted/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('a second driver cannot bypass the rover cooldown with their own fresh actor window', async () => {
|
||||
const { handlers, issued } = createHarness();
|
||||
await handlers.honk(message(), []);
|
||||
|
||||
const other = message({ id: 's2', userId: 'u-bob', label: 'bob' });
|
||||
await handlers.honk(other, []);
|
||||
assert.match(other.replies[0].content, /was just honked/);
|
||||
// Only the first honk reached the rover.
|
||||
assert.equal(issued.filter((entry) => entry.horn?.action === 'start').length, 1);
|
||||
});
|
||||
|
||||
test('an offline rover reports offline instead of claiming a honk happened', async () => {
|
||||
const { handlers } = createHarness({ online: false });
|
||||
const msg = message();
|
||||
await handlers.honk(msg, []);
|
||||
assert.match(msg.replies[0].content, /is offline/);
|
||||
});
|
||||
|
||||
test('spin clamps to the rover wheel speed ceiling and always stops itself', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { handlers, issued } = createHarness({ maxWheelSpeed: 50 });
|
||||
const msg = message();
|
||||
await handlers.spin(msg, []);
|
||||
|
||||
assert.equal(issued[0].driveDirect.left, 50);
|
||||
assert.equal(issued[0].driveDirect.right, -50);
|
||||
|
||||
t.mock.timers.tick(2000);
|
||||
assert.deepEqual(issued[1].driveDirect, { left: 0, right: 0 });
|
||||
});
|
||||
|
||||
test('spin honours a private rover safety override rather than bypassing it', async () => {
|
||||
const { handlers, issued } = createHarness({ privateSafetyDrive: { left: 20, right: -20 } });
|
||||
await handlers.spin(message(), []);
|
||||
assert.deepEqual(issued[0].driveDirect, { left: 20, right: -20 });
|
||||
});
|
||||
|
||||
test('spin is refused without drive control', async () => {
|
||||
const { handlers, issued } = createHarness({ canDrive: false });
|
||||
const msg = message();
|
||||
await handlers.spin(msg, []);
|
||||
assert.match(msg.replies[0].content, /need control/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('boo speaks a canned taunt rather than any caller supplied text', async () => {
|
||||
const { handlers, issued } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
|
||||
const msg = message();
|
||||
await handlers.boo(msg, ['bob']);
|
||||
|
||||
assert.equal(issued.length, 1);
|
||||
assert.equal(issued[0].type, 'tts');
|
||||
// The spoken text must not contain anything the caller typed.
|
||||
assert.doesNotMatch(issued[0].tts.text, /bob/i);
|
||||
assert.ok(issued[0].tts.text.length > 0);
|
||||
});
|
||||
|
||||
test('boo is refused when the target is not driving anything', async () => {
|
||||
const { handlers, issued } = createHarness({ activeDrivers: {} });
|
||||
const msg = message();
|
||||
await handlers.boo(msg, ['bob']);
|
||||
|
||||
assert.match(msg.replies[0].content, /not driving anything/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('boo is refused when the target is not online at all', async () => {
|
||||
const { handlers, issued } = createHarness({ sockets: [ALICE] });
|
||||
const msg = message();
|
||||
await handlers.boo(msg, ['nobody-here']);
|
||||
|
||||
assert.match(msg.replies[0].content, /not here to be booed/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('boo is refused on a rover that cannot speak', async () => {
|
||||
const { handlers, issued } = createHarness({ ttsEnabled: false, activeDrivers: { 'rover-1': 's2' } });
|
||||
const msg = message();
|
||||
await handlers.boo(msg, ['bob']);
|
||||
|
||||
assert.match(msg.replies[0].content, /cannot speak/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('disco is unavailable when the Home Assistant feature is off', async () => {
|
||||
const calls = [];
|
||||
const { handlers } = createHarness({
|
||||
featureEnabled: false,
|
||||
homeAssistantService: {
|
||||
getLightPolicyState: () => ({}),
|
||||
setAllControllableEntitiesState: (state) => calls.push(state),
|
||||
},
|
||||
});
|
||||
const msg = message();
|
||||
await handlers.disco(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /unavailable/);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('disco obeys the room light lock', async () => {
|
||||
const calls = [];
|
||||
const { handlers } = createHarness({
|
||||
featureEnabled: true,
|
||||
homeAssistantService: {
|
||||
getLightPolicyState: () => ({ locked: true, lockState: 'on' }),
|
||||
setAllControllableEntitiesState: (state) => calls.push(state),
|
||||
},
|
||||
});
|
||||
const msg = message();
|
||||
await handlers.disco(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /locked/);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('disco strobes while unlocked and restores the lights on when it ends', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] });
|
||||
const calls = [];
|
||||
const { handlers } = createHarness({
|
||||
featureEnabled: true,
|
||||
homeAssistantService: {
|
||||
getLightPolicyState: () => ({ locked: false }),
|
||||
setAllControllableEntitiesState: (state) => {
|
||||
calls.push(state);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
});
|
||||
const msg = message();
|
||||
await handlers.disco(msg, []);
|
||||
assert.match(msg.replies[0].content, /Disco/);
|
||||
|
||||
t.mock.timers.tick(3000);
|
||||
assert.ok(calls.length >= 2, `expected several ticks, saw ${calls.length}`);
|
||||
assert.ok(calls.includes('on') && calls.includes('off'));
|
||||
|
||||
// Past the end of the window the lights must be put back on and left alone.
|
||||
t.mock.timers.tick(20 * 1000);
|
||||
assert.equal(calls[calls.length - 1], 'on');
|
||||
const settled = calls.length;
|
||||
t.mock.timers.tick(20 * 1000);
|
||||
assert.equal(calls.length, settled);
|
||||
});
|
||||
|
||||
test('vibecheck reports the battery and never issues a command', async () => {
|
||||
const { handlers, issued } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.vibecheck(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /Roomba One/);
|
||||
assert.match(msg.replies[0].content, /Battery 74%/);
|
||||
assert.equal(issued.length, 0);
|
||||
});
|
||||
|
||||
test('vibecheck leads with the real problem when the battery is urgent', async () => {
|
||||
const { handlers, record } = createHarness();
|
||||
record.batteryState = { percentDisplay: 4, warnActive: true, urgentActive: true };
|
||||
const msg = message();
|
||||
await handlers.vibecheck(msg, []);
|
||||
assert.match(msg.replies[0].content, /dying/);
|
||||
});
|
||||
|
||||
test('vibecheck reports an offline rover as offline', async () => {
|
||||
const { handlers, record } = createHarness();
|
||||
record.ws = null;
|
||||
const msg = message();
|
||||
await handlers.vibecheck(msg, []);
|
||||
assert.match(msg.replies[0].content, /offline/);
|
||||
});
|
||||
|
||||
test('describeBattery falls back through the available fields', () => {
|
||||
assert.equal(describeBattery({ percentDisplay: 55.4 }), '55%');
|
||||
assert.equal(describeBattery({ percent: 0.42 }), '42%');
|
||||
assert.equal(describeBattery({}), 'unknown');
|
||||
assert.equal(describeBattery(null), 'unknown');
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Operator Fun Stats Command Tests
|
||||
// Purpose: Verifies the leaderboard ordering, rover pet counting, and what snitch reports.
|
||||
// Scope: In-memory stats and roster doubles only.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createFunStatsCommands, formatLeaderboard } = require('./funStats');
|
||||
const { createCooldownGate } = require('../cooldowns');
|
||||
|
||||
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
|
||||
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
|
||||
|
||||
function createHarness({
|
||||
actorRows = [],
|
||||
activeDrivers = {},
|
||||
socket = ALICE,
|
||||
rovers = new Map([
|
||||
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
|
||||
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
|
||||
]),
|
||||
} = {}) {
|
||||
const pets = new Map();
|
||||
const handlers = createFunStatsCommands({
|
||||
io: { sockets: { sockets: new Map([[ALICE.id, ALICE], [BOB.id, BOB]]) } },
|
||||
rovers,
|
||||
getNickname: (entry) => entry?.data?.nickname || '',
|
||||
getActiveDrivers: () => activeDrivers,
|
||||
getActorSocket: () => socket,
|
||||
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
|
||||
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
|
||||
funStatsService: {
|
||||
listActorStats: () => actorRows,
|
||||
bumpRoverPets: (roverId, by) => {
|
||||
const next = (pets.get(roverId) || 0) + by;
|
||||
pets.set(roverId, next);
|
||||
return next;
|
||||
},
|
||||
},
|
||||
cooldowns: createCooldownGate(),
|
||||
config: { commands: { prefix: 'rs' } },
|
||||
});
|
||||
return { handlers, pets };
|
||||
}
|
||||
|
||||
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
|
||||
const replies = [];
|
||||
return {
|
||||
transport: 'web-chat',
|
||||
actor,
|
||||
replies,
|
||||
reply: async (payload) => {
|
||||
replies.push(payload);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the leaderboard sorts descending and drops zero scores', () => {
|
||||
const rows = [
|
||||
{ label: 'alice', bonksGiven: 2 },
|
||||
{ label: 'bob', bonksGiven: 9 },
|
||||
{ label: 'carol', bonksGiven: 0 },
|
||||
];
|
||||
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
|
||||
const lines = rendered.split('\n');
|
||||
assert.equal(lines[1], '1. bob — 9');
|
||||
assert.equal(lines[2], '2. alice — 2');
|
||||
assert.equal(lines.length, 3, 'carol should not appear with a zero score');
|
||||
});
|
||||
|
||||
test('the leaderboard is capped at ten entries', () => {
|
||||
const rows = Array.from({ length: 25 }, (_, index) => ({ label: `user${index}`, bonksGiven: index + 1 }));
|
||||
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
|
||||
assert.equal(rendered.split('\n').length - 1, 10);
|
||||
});
|
||||
|
||||
test('an all-zero counter renders no section at all', () => {
|
||||
assert.equal(formatLeaderboard('Most bonks dealt', [{ label: 'alice', bonksGiven: 0 }], 'bonksGiven'), null);
|
||||
});
|
||||
|
||||
test('bonkboard says so when nothing has happened yet', async () => {
|
||||
const { handlers } = createHarness({ actorRows: [] });
|
||||
const msg = message();
|
||||
await handlers.bonkboard(msg, []);
|
||||
assert.match(msg.replies[0].content, /Nobody has been bonked yet/);
|
||||
});
|
||||
|
||||
test('bonkboard renders each populated section', async () => {
|
||||
const { handlers } = createHarness({
|
||||
actorRows: [
|
||||
{ label: 'alice', bonksGiven: 3, bonksTaken: 0, hugsGiven: 1 },
|
||||
{ label: 'bob', bonksGiven: 0, bonksTaken: 3, hugsGiven: 0 },
|
||||
],
|
||||
});
|
||||
const msg = message();
|
||||
await handlers.bonkboard(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /Most bonks dealt/);
|
||||
assert.match(msg.replies[0].content, /Most bonks taken/);
|
||||
assert.match(msg.replies[0].content, /Most hugs given/);
|
||||
});
|
||||
|
||||
test('bonkboard sanitizes stored labels, so a hostile nickname cannot ping a guild', async () => {
|
||||
const { handlers } = createHarness({ actorRows: [{ label: '@everyone', bonksGiven: 1 }] });
|
||||
const msg = message();
|
||||
await handlers.bonkboard(msg, []);
|
||||
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
|
||||
});
|
||||
|
||||
test('pet counts against the rover the caller is on when none is named', async () => {
|
||||
const { handlers, pets } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.pet(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /pets Roomba One/);
|
||||
assert.match(msg.replies[0].content, /petted 1 time\./);
|
||||
assert.equal(pets.get('rover-1'), 1);
|
||||
});
|
||||
|
||||
test('pet accepts an explicit rover and keeps a separate count per rover', async () => {
|
||||
const { handlers, pets } = createHarness();
|
||||
await handlers.pet(message(), ['Roomba Two']);
|
||||
await handlers.pet(message({ id: 's2', userId: 'u-bob', label: 'bob' }), ['Roomba Two']);
|
||||
|
||||
assert.equal(pets.get('rover-2'), 2);
|
||||
assert.equal(pets.get('rover-1'), undefined);
|
||||
});
|
||||
|
||||
test('pet pluralizes the running total', async () => {
|
||||
const { handlers } = createHarness();
|
||||
await handlers.pet(message(), []);
|
||||
const second = message({ id: 's2', userId: 'u-bob', label: 'bob' });
|
||||
await handlers.pet(second, []);
|
||||
assert.match(second.replies[0].content, /petted 2 times\./);
|
||||
});
|
||||
|
||||
test('pet from a transport with no socket asks for a rover name', async () => {
|
||||
const { handlers, pets } = createHarness({ socket: null });
|
||||
const msg = message({ id: '4242', label: 'DiscordUser' });
|
||||
await handlers.pet(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /Name a rover/);
|
||||
assert.equal(pets.size, 0);
|
||||
});
|
||||
|
||||
test('snitch names the active driver and reports idle rovers as nobody', async () => {
|
||||
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
|
||||
const msg = message();
|
||||
await handlers.snitch(msg, []);
|
||||
|
||||
assert.match(msg.replies[0].content, /Roomba One — bob/);
|
||||
assert.match(msg.replies[0].content, /Roomba Two — nobody/);
|
||||
});
|
||||
|
||||
test('snitch handles a driver socket that has already gone away', async () => {
|
||||
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 'ghost-socket' } });
|
||||
const msg = message();
|
||||
await handlers.snitch(msg, []);
|
||||
assert.match(msg.replies[0].content, /Roomba One — someone who will not say their name/);
|
||||
});
|
||||
|
||||
test('snitch reports an empty fleet rather than an empty message', async () => {
|
||||
const { handlers } = createHarness({ rovers: new Map() });
|
||||
const msg = message();
|
||||
await handlers.snitch(msg, []);
|
||||
assert.match(msg.replies[0].content, /No rovers are online/);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// Operator Fun Text Command Tests
|
||||
// Purpose: Verifies tally credit, self-targeting, cooldown refusal, mention sanitizing, and dice parsing.
|
||||
// Scope: Uses an in-memory stats double so no test touches the fun stats file.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createFunTextCommands, parseDiceSpec, uwuify } = require('./funText');
|
||||
const { createCooldownGate } = require('../cooldowns');
|
||||
|
||||
function createStatsDouble() {
|
||||
const store = new Map();
|
||||
return {
|
||||
calls: [],
|
||||
bumpActorStats(actorKey, { label = null, ...patch } = {}) {
|
||||
this.calls.push({ actorKey, label, patch });
|
||||
const current = store.get(actorKey) || {};
|
||||
const next = { ...current, label: label || current.label };
|
||||
Object.keys(patch).forEach((key) => {
|
||||
next[key] = (Number(current[key]) || 0) + Number(patch[key] || 0);
|
||||
});
|
||||
store.set(actorKey, next);
|
||||
return next;
|
||||
},
|
||||
getActorStats(actorKey) {
|
||||
return store.get(actorKey) || {};
|
||||
},
|
||||
listActorStats() {
|
||||
return Array.from(store.entries()).map(([actorKey, value]) => ({ actorKey, ...value }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness({ sockets = [] } = {}) {
|
||||
const stats = createStatsDouble();
|
||||
const handlers = createFunTextCommands({
|
||||
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
|
||||
getNickname: (entry) => entry?.data?.nickname || '',
|
||||
// Matches the real sanitizer so tests exercise the actual escaping rules.
|
||||
sanitizeMentions: (text) => String(text || '')
|
||||
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
||||
.replace(/@everyone/gi, '[everyone]')
|
||||
.replace(/@here/gi, '[here]'),
|
||||
funStatsService: stats,
|
||||
cooldowns: createCooldownGate(),
|
||||
config: { commands: { prefix: 'rs' } },
|
||||
});
|
||||
return { handlers, stats };
|
||||
}
|
||||
|
||||
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
|
||||
const replies = [];
|
||||
return {
|
||||
transport: 'web-chat',
|
||||
actor,
|
||||
replies,
|
||||
reply: async (payload) => {
|
||||
replies.push(payload);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bob = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
|
||||
|
||||
test('bonk credits both sides and reports the running tally', async () => {
|
||||
const { handlers, stats } = createHarness({ sockets: [bob] });
|
||||
const msg = message();
|
||||
await handlers.bonk(msg, ['bob']);
|
||||
|
||||
assert.match(msg.replies[0].content, /Bonked bob\./);
|
||||
assert.match(msg.replies[0].content, /1st bonk/);
|
||||
assert.deepEqual(
|
||||
stats.calls.map((call) => [call.actorKey, Object.keys(call.patch)[0]]),
|
||||
[['user:u-alice', 'bonksGiven'], ['user:u-bob', 'bonksTaken']],
|
||||
);
|
||||
});
|
||||
|
||||
test('the tally ordinal advances across repeat bonks', async () => {
|
||||
const { handlers } = createHarness({ sockets: [bob] });
|
||||
await handlers.bonk(message(), ['bob']);
|
||||
// A second actor avoids the first actor's cooldown while still hitting bob.
|
||||
const second = message({ id: 's3', userId: 'u-carol', label: 'carol' });
|
||||
await handlers.bonk(second, ['bob']);
|
||||
assert.match(second.replies[0].content, /2nd bonk/);
|
||||
});
|
||||
|
||||
test('bonking an offline name still replies but credits nobody', async () => {
|
||||
const { handlers, stats } = createHarness({ sockets: [bob] });
|
||||
const msg = message();
|
||||
await handlers.bonk(msg, ['the', 'dishwasher']);
|
||||
|
||||
assert.match(msg.replies[0].content, /Bonked the dishwasher\./);
|
||||
assert.doesNotMatch(msg.replies[0].content, /bonk\b.*\dst|\dnd|\drd|\dth/);
|
||||
assert.deepEqual(stats.calls.map((call) => call.actorKey), ['user:u-alice']);
|
||||
});
|
||||
|
||||
test('self-bonking is a special case and records nothing', async () => {
|
||||
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
|
||||
const { handlers, stats } = createHarness({ sockets: [alice] });
|
||||
const msg = message();
|
||||
await handlers.bonk(msg, ['alice']);
|
||||
|
||||
assert.match(msg.replies[0].content, /themselves/);
|
||||
assert.equal(stats.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a repeat inside the cooldown window is refused and records nothing extra', async () => {
|
||||
const { handlers, stats } = createHarness({ sockets: [bob] });
|
||||
await handlers.bonk(message(), ['bob']);
|
||||
const countAfterFirst = stats.calls.length;
|
||||
|
||||
const second = message();
|
||||
await handlers.bonk(second, ['bob']);
|
||||
assert.match(second.replies[0].content, /Slow down/);
|
||||
assert.equal(stats.calls.length, countAfterFirst);
|
||||
});
|
||||
|
||||
test('cooldowns are per command, so a bonk does not block a hug', async () => {
|
||||
const { handlers } = createHarness({ sockets: [bob] });
|
||||
await handlers.bonk(message(), ['bob']);
|
||||
const hug = message();
|
||||
await handlers.hug(hug, ['bob']);
|
||||
assert.doesNotMatch(hug.replies[0].content, /Slow down/);
|
||||
});
|
||||
|
||||
test('a missing target replies with usage and does not burn the cooldown', async () => {
|
||||
const { handlers } = createHarness({ sockets: [bob] });
|
||||
const first = message();
|
||||
await handlers.bonk(first, []);
|
||||
assert.match(first.replies[0].content, /Usage: `rs bonk <user>`/);
|
||||
|
||||
const second = message();
|
||||
await handlers.bonk(second, ['bob']);
|
||||
assert.match(second.replies[0].content, /Bonked bob/);
|
||||
});
|
||||
|
||||
test('every reply is sanitized so a fun command cannot ping a whole guild', async () => {
|
||||
const { handlers } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.bonk(msg, ['@everyone']);
|
||||
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
|
||||
assert.match(msg.replies[0].content, /\[everyone\]/);
|
||||
|
||||
const roleMsg = message({ id: 's9', userId: 'u-dave', label: 'dave' });
|
||||
await handlers.slap(roleMsg, ['<@&123456>']);
|
||||
assert.match(roleMsg.replies[0].content, /\[ping removed\]/);
|
||||
});
|
||||
|
||||
test('an actor with no identity at all is refused rather than sharing a tally', async () => {
|
||||
const { handlers, stats } = createHarness({ sockets: [bob] });
|
||||
const msg = message({ label: 'ghost' });
|
||||
await handlers.bonk(msg, ['bob']);
|
||||
assert.match(msg.replies[0].content, /Could not identify you/);
|
||||
assert.equal(stats.calls.length, 0);
|
||||
});
|
||||
|
||||
test('ship agrees with itself regardless of argument order', async () => {
|
||||
const { handlers } = createHarness();
|
||||
const forward = message();
|
||||
await handlers.ship(forward, ['alice', 'and', 'bob']);
|
||||
|
||||
const { handlers: other } = createHarness();
|
||||
const backward = message();
|
||||
await other.ship(backward, ['bob', 'and', 'alice']);
|
||||
|
||||
const score = (text) => /\*\*(\d+)%\*\*/.exec(text)[1];
|
||||
assert.equal(score(forward.replies[0].content), score(backward.replies[0].content));
|
||||
});
|
||||
|
||||
test('ship needs two sides', async () => {
|
||||
const { handlers } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.ship(msg, ['alice']);
|
||||
assert.match(msg.replies[0].content, /Usage: `rs ship/);
|
||||
});
|
||||
|
||||
test('8ball gives the same answer to the same question', async () => {
|
||||
const first = createHarness();
|
||||
const a = message();
|
||||
await first.handlers['8ball'](a, ['will', 'it', 'dock']);
|
||||
|
||||
const second = createHarness();
|
||||
const b = message();
|
||||
await second.handlers['8ball'](b, ['WILL', 'IT', 'DOCK']);
|
||||
|
||||
assert.equal(a.replies[0].content.split('\n')[1], b.replies[0].content.split('\n')[1]);
|
||||
});
|
||||
|
||||
test('rate stays inside 0 to 10', async () => {
|
||||
for (const thing of ['carpet', 'the dock', 'a', 'zzzzzz', 'rover 3']) {
|
||||
const { handlers } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.rate(msg, [thing]);
|
||||
const score = Number(/\*\*(\d+)\/10\*\*/.exec(msg.replies[0].content)[1]);
|
||||
assert.ok(score >= 0 && score <= 10, `${thing} scored ${score}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('dice specs parse the accepted forms and reject the rest', () => {
|
||||
assert.deepEqual(parseDiceSpec('2d6'), { count: 2, sides: 6 });
|
||||
assert.deepEqual(parseDiceSpec('d20'), { count: 1, sides: 20 });
|
||||
assert.deepEqual(parseDiceSpec(''), { count: 1, sides: 6 });
|
||||
// A bare number is read as one die of that many sides.
|
||||
assert.deepEqual(parseDiceSpec('20'), { count: 1, sides: 20 });
|
||||
assert.match(parseDiceSpec('21d6').error, /between 1 and 20 dice/);
|
||||
assert.match(parseDiceSpec('1d1').error, /2 and 1000 sides/);
|
||||
assert.match(parseDiceSpec('1d2000').error, /2 and 1000 sides/);
|
||||
assert.match(parseDiceSpec('banana').error, /NdN/);
|
||||
});
|
||||
|
||||
test('roll totals stay within the possible range for the spec', async () => {
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const { handlers } = createHarness();
|
||||
const msg = message();
|
||||
await handlers.roll(msg, ['3d6']);
|
||||
const total = Number(/\*\*(\d+)\*\*/.exec(msg.replies[0].content)[1]);
|
||||
assert.ok(total >= 3 && total <= 18, `rolled ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('uwu transforms text without dropping it', () => {
|
||||
assert.equal(uwuify('hello world'), 'hewwo wowwd');
|
||||
assert.equal(uwuify('love'), 'wuv');
|
||||
assert.equal(uwuify('nice'), 'nyice');
|
||||
});
|
||||
|
||||
test('wanted includes prior bonks when the target has any on record', async () => {
|
||||
const { handlers } = createHarness({ sockets: [bob] });
|
||||
await handlers.bonk(message(), ['bob']);
|
||||
|
||||
const msg = message({ id: 's4', userId: 'u-erin', label: 'erin' });
|
||||
await handlers.wanted(msg, ['bob']);
|
||||
assert.match(msg.replies[0].content, /WANTED/);
|
||||
assert.match(msg.replies[0].content, /Prior bonks on record: 1/);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// Operator Command Cooldown Tests
|
||||
// Purpose: Verifies the per-actor rate limit opens and closes on the boundaries callers rely on.
|
||||
// Scope: Pure; the gate takes an injected clock so no test needs to sleep.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createCooldownGate, describeWait } = require('./cooldowns');
|
||||
|
||||
test('first use passes and an immediate repeat is refused', () => {
|
||||
const gate = createCooldownGate();
|
||||
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
|
||||
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
|
||||
assert.equal(gate.consume('bonk:alice', 1000, 400), 600);
|
||||
});
|
||||
|
||||
test('the window reopens exactly when it expires', () => {
|
||||
const gate = createCooldownGate();
|
||||
gate.consume('honk:alice', 1000, 0);
|
||||
assert.equal(gate.consume('honk:alice', 1000, 999), 1);
|
||||
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
|
||||
});
|
||||
|
||||
test('a refused call does not extend the existing window', () => {
|
||||
const gate = createCooldownGate();
|
||||
gate.consume('honk:alice', 1000, 0);
|
||||
// Hammering the gate at t=500 must not push the reopen time out to t=1500.
|
||||
gate.consume('honk:alice', 1000, 500);
|
||||
gate.consume('honk:alice', 1000, 900);
|
||||
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
|
||||
});
|
||||
|
||||
test('cooldowns are scoped per key so different actors and commands do not collide', () => {
|
||||
const gate = createCooldownGate();
|
||||
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
|
||||
assert.equal(gate.consume('bonk:bob', 1000, 0), 0);
|
||||
assert.equal(gate.consume('hug:alice', 1000, 0), 0);
|
||||
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
|
||||
});
|
||||
|
||||
test('a missing key or non-positive window never gates', () => {
|
||||
const gate = createCooldownGate();
|
||||
assert.equal(gate.consume('', 1000, 0), 0);
|
||||
assert.equal(gate.consume('bonk:alice', 0, 0), 0);
|
||||
assert.equal(gate.consume('bonk:alice', -5, 0), 0);
|
||||
// None of the above should have armed anything.
|
||||
assert.equal(gate.remaining('bonk:alice', 0), 0);
|
||||
});
|
||||
|
||||
test('describeWait rounds up and switches to minutes', () => {
|
||||
assert.equal(describeWait(1), '1s');
|
||||
assert.equal(describeWait(4200), '5s');
|
||||
assert.equal(describeWait(60 * 1000), '1m');
|
||||
assert.equal(describeWait(95 * 1000), '1m 35s');
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Operator Command Dispatcher Tests
|
||||
// Purpose: Pins the permission, mode, and prefix policy that the registry-driven gate replaced a hardcoded action list with.
|
||||
// Scope: Exercises the router with doubles; individual command behavior is covered by each command's own tests.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createCommandHandlers } = require('./index');
|
||||
|
||||
const MODES = { OPEN: 'open', TURNS: 'turns', ADMIN: 'admin', LOCKDOWN: 'lockdown' };
|
||||
const ADMIN_DENIAL = /Only admins can run that command/;
|
||||
const LOCKDOWN_DENIAL = /Lockdown mode: only lockdown admins/;
|
||||
const FEATURE_DENIAL = /Admin mode: only admins can run feature commands/;
|
||||
|
||||
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
|
||||
|
||||
function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
|
||||
const rovers = new Map([['rover-1', {
|
||||
id: 'rover-1',
|
||||
ws: {},
|
||||
meta: { name: 'Roomba One', horn: { enabled: true } },
|
||||
batteryState: { percentDisplay: 50 },
|
||||
}]]);
|
||||
|
||||
const { handleCommand } = createCommandHandlers({
|
||||
logger: { warn: () => {}, info: () => {} },
|
||||
io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } },
|
||||
rovers,
|
||||
roverManager: {
|
||||
canDrive: () => true,
|
||||
getPrimaryRoverForSocket: () => 'rover-1',
|
||||
applyPrivateDriveSafety: () => null,
|
||||
},
|
||||
getMode: () => mode,
|
||||
MODES,
|
||||
getNickname: (entry) => entry?.data?.nickname || '',
|
||||
getActiveDrivers: () => ({}),
|
||||
getActorSocket: () => ALICE,
|
||||
issueCommand: () => 'cmd-1',
|
||||
isFeatureEnabled: () => featureEnabled,
|
||||
sanitizeMentions: (text) => String(text || ''),
|
||||
funStatsService: {
|
||||
bumpActorStats: () => ({}),
|
||||
getActorStats: () => ({}),
|
||||
listActorStats: () => [],
|
||||
bumpRoverPets: () => 1,
|
||||
getRoverPets: () => 0,
|
||||
},
|
||||
homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() },
|
||||
liftService: null,
|
||||
neatoService: null,
|
||||
listVerifiedUsers: () => [],
|
||||
listDeterredUsers: () => [],
|
||||
listMutedUsers: () => [],
|
||||
getGlobalObjective: () => null,
|
||||
getAdminReason: () => null,
|
||||
config: { commands: { prefix: 'rs' } },
|
||||
transportHandlers: {
|
||||
status: async (request) => request.reply({ content: '[status handler]' }),
|
||||
bridge: async (request) => request.reply({ content: '[bridge handler]' }),
|
||||
},
|
||||
});
|
||||
|
||||
return async function run(text, actor) {
|
||||
const replies = [];
|
||||
await handleCommand({
|
||||
content: text,
|
||||
transport: 'web-chat',
|
||||
actor,
|
||||
reply: async (payload) => {
|
||||
replies.push(typeof payload === 'string' ? payload : payload?.content);
|
||||
},
|
||||
});
|
||||
return replies.join('\n');
|
||||
};
|
||||
}
|
||||
|
||||
const nonAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: false, isLockdownAdmin: false };
|
||||
const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: false };
|
||||
const lockdownAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: true };
|
||||
|
||||
test('a non-admin can run fun commands', async () => {
|
||||
const run = createRouter();
|
||||
for (const command of ['rs coin', 'rs rate carpet', 'rs uwu hi', 'rs bonkboard', 'rs vibecheck', 'rs snitch']) {
|
||||
const reply = await run(command, nonAdmin);
|
||||
assert.doesNotMatch(reply, ADMIN_DENIAL, `${command} should be public`);
|
||||
assert.ok(reply.length > 0, `${command} should reply`);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin-only commands stay admin-only for a non-admin', async () => {
|
||||
const run = createRouter();
|
||||
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs kick alice']) {
|
||||
assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`);
|
||||
}
|
||||
});
|
||||
|
||||
test('commands that police themselves still reach their handler as a non-admin', async () => {
|
||||
const run = createRouter();
|
||||
// These reply with their own role-specific message, so the dispatcher must not
|
||||
// short-circuit them with the generic admin denial.
|
||||
for (const command of ['rs goal', 'rs reason', 'rs verify list', 'rs deter list']) {
|
||||
assert.doesNotMatch(await run(command, nonAdmin), ADMIN_DENIAL, `${command} enforces its own permission`);
|
||||
}
|
||||
});
|
||||
|
||||
test('system commands remain reachable by anyone', async () => {
|
||||
const run = createRouter();
|
||||
assert.match(await run('rs status', nonAdmin), /\[status handler\]/);
|
||||
assert.match(await run('rs', nonAdmin), /\[status handler\]/);
|
||||
assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/);
|
||||
});
|
||||
|
||||
test('the fun category appears in help', async () => {
|
||||
const run = createRouter();
|
||||
const help = await run('rs help fun', nonAdmin);
|
||||
assert.match(help, /\*\*Fun\*\*/);
|
||||
for (const command of ['bonk', 'honk', 'disco', 'vibecheck', 'bonkboard']) {
|
||||
assert.match(help, new RegExp(`rs ${command}`), `help should list ${command}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin mode restricts access-mode feature commands but not the public fun ones', async () => {
|
||||
const run = createRouter({ mode: MODES.ADMIN });
|
||||
assert.match(await run('rs lights on', nonAdmin), FEATURE_DENIAL);
|
||||
assert.match(await run('rs disco', nonAdmin), FEATURE_DENIAL);
|
||||
assert.doesNotMatch(await run('rs coin', nonAdmin), FEATURE_DENIAL);
|
||||
});
|
||||
|
||||
test('lockdown suspends the whole fun category for anyone but a lockdown admin', async () => {
|
||||
const run = createRouter({ mode: MODES.LOCKDOWN });
|
||||
for (const command of ['rs coin', 'rs bonk bob', 'rs honk', 'rs disco', 'rs vibecheck']) {
|
||||
assert.match(await run(command, nonAdmin), LOCKDOWN_DENIAL, `${command} should be suspended in lockdown`);
|
||||
}
|
||||
// A plain admin is not enough during lockdown.
|
||||
assert.match(await run('rs coin', admin), LOCKDOWN_DENIAL);
|
||||
assert.doesNotMatch(await run('rs coin', lockdownAdmin), LOCKDOWN_DENIAL);
|
||||
});
|
||||
|
||||
test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => {
|
||||
const run = createRouter({ mode: MODES.LOCKDOWN });
|
||||
for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs goal', 'rs kick alice']) {
|
||||
assert.match(await run(command, admin), LOCKDOWN_DENIAL, `${command} should stay lockdown-gated`);
|
||||
}
|
||||
});
|
||||
|
||||
test('status and help survive lockdown', async () => {
|
||||
const run = createRouter({ mode: MODES.LOCKDOWN });
|
||||
assert.match(await run('rs status', nonAdmin), /\[status handler\]/);
|
||||
assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/);
|
||||
});
|
||||
|
||||
test('a disabled required feature is reported before any permission check', async () => {
|
||||
const run = createRouter({ featureEnabled: false });
|
||||
assert.match(await run('rs disco', nonAdmin), /Home Assistant feature is not configured/);
|
||||
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/);
|
||||
});
|
||||
|
||||
test('ordinary words that merely start with the prefix are not commands', async () => {
|
||||
const run = createRouter();
|
||||
assert.equal(await run('rsvp', nonAdmin), '');
|
||||
assert.equal(await run('rspecial delivery', nonAdmin), '');
|
||||
assert.equal(await run('hello there', nonAdmin), '');
|
||||
});
|
||||
|
||||
test('an unknown command is not treated as public', async () => {
|
||||
const run = createRouter();
|
||||
// Unknown actions carry no registry entry, so they must fall through to the
|
||||
// same admin denial they did before the permission refactor.
|
||||
assert.match(await run('rs notacommand', nonAdmin), ADMIN_DENIAL);
|
||||
assert.match(await run('rs notacommand', admin), /Rover Bot Commands/);
|
||||
});
|
||||
|
||||
test('bot actors are ignored entirely', async () => {
|
||||
const run = createRouter();
|
||||
assert.equal(await run('rs coin', { ...nonAdmin, bot: true }), '');
|
||||
});
|
||||
|
||||
test('command matching is case insensitive', async () => {
|
||||
const run = createRouter();
|
||||
assert.doesNotMatch(await run('RS COIN', nonAdmin), ADMIN_DENIAL);
|
||||
assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/);
|
||||
});
|
||||
Reference in New Issue
Block a user