Files
TREK/plugin-sdk/test/mock-limits.test.ts
T
jubnl 77dddd5e8a v4.0.0 (#1719)
Release branch for 4.0.0.
2026-08-27 13:22:39 +02:00

135 lines
6.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createMockHost } from '../src/index.js';
/**
* Mock-host fidelity for the two host-mediated daily budgets (ai.complete/extract and
* notify.send, daily-budget.ts) and the ctx.meta quotas (meta.rpc.ts). Error strings
* and check order are copied verbatim from the server so a plugin author sees the
* exact same failure locally that production would give them.
*/
describe('daily budgets', () => {
it('exhausts the AI budget', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 2 });
await host.ctx.ai.complete('hi');
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('ai.extract shares the same per-host AI budget as ai.complete', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 1 });
await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.extract('text', {})).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('aiPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1, aiPerDay: 0 });
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('defaults to 200/day for ai, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['ai:invoke'], actingUserId: 1 });
for (let i = 0; i < 200; i++) await host.ctx.ai.complete('hi');
await expect(host.ctx.ai.complete('hi')).rejects.toThrow('daily AI budget exhausted (resets at UTC midnight)');
});
it('exhausts the notify budget', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 1,
});
await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('notifyPerDay: 0 disables the broker — the first call fails', async () => {
const host = createMockHost({
grants: ['notify:send'],
actingUserId: 1,
trips: { 1: { members: [1] } },
notifyPerDay: 0,
});
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
it('defaults to 100/day for notify, far above what a normal test suite calls', async () => {
const host = createMockHost({ grants: ['notify:send'], actingUserId: 1, trips: { 1: { members: [1] } } });
for (let i = 0; i < 100; i++) await host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 });
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 1 })).rejects.toThrow(
'daily notification budget exhausted (resets at UTC midnight)',
);
});
});
describe('ctx.meta quotas', () => {
const fixture = () =>
createMockHost({ grants: ['db:meta'], actingUserId: 1, trips: { 1: { members: [1] } } });
it('meta value quota: 65536 bytes passes, 65537 rejects', async () => {
const { ctx } = fixture();
// Size the payload via Buffer.byteLength(JSON.stringify(v)), same as the server's
// notion of "serialized JSON bytes" — a bare string serializes with 2 quote bytes,
// so budget those into the repeat count.
const okValue = 'x'.repeat(65536 - 2);
expect(Buffer.byteLength(JSON.stringify(okValue), 'utf8')).toBe(65536);
await expect(ctx.meta.set('trip', 1, 'k', okValue)).resolves.toEqual({ key: 'k', value: okValue });
const tooBig = 'x'.repeat(65537 - 2);
expect(Buffer.byteLength(JSON.stringify(tooBig), 'utf8')).toBe(65537);
await expect(ctx.meta.set('trip', 1, 'k2', tooBig)).rejects.toThrow('metadata value too large (>65536 bytes)');
});
it('meta key length: 256 chars passes, 257 rejects', async () => {
const { ctx } = fixture();
const okKey = 'k'.repeat(256);
await expect(ctx.meta.set('trip', 1, okKey, 'v')).resolves.toEqual({ key: okKey, value: 'v' });
const tooLongKey = 'k'.repeat(257);
await expect(ctx.meta.set('trip', 1, tooLongKey, 'v')).rejects.toThrow('metadata key too long (>256 chars)');
});
it('meta key count: 100 keys per (plugin, entity) passes, the 101st rejects', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(ctx.meta.set('trip', 1, 'key-100', 100)).rejects.toThrow(
'too many metadata keys on this trip (max 100)',
);
});
it('overwriting an existing key never counts against the 100-key cap', async () => {
const { ctx } = fixture();
for (let i = 0; i < 100; i++) {
await ctx.meta.set('trip', 1, `key-${i}`, i);
}
// The entity already has 100 keys; updating one of them must still succeed.
await expect(ctx.meta.set('trip', 1, 'key-0', 'updated')).resolves.toEqual({ key: 'key-0', value: 'updated' });
});
it('checks in server order: key length before value size before key count', async () => {
const { ctx } = fixture();
// A key that is simultaneously too long AND paired with an oversized value must
// fail on the key-length check first.
const tooLongKey = 'k'.repeat(257);
const tooBigValue = 'x'.repeat(70000);
await expect(ctx.meta.set('trip', 1, tooLongKey, tooBigValue)).rejects.toThrow('metadata key too long (>256 chars)');
// Fill the entity to the cap, then a value that is ALSO too large must fail on
// the value-size check (raised before the key-count check ever runs).
const full = fixture();
for (let i = 0; i < 100; i++) {
await full.ctx.meta.set('trip', 1, `key-${i}`, i);
}
await expect(full.ctx.meta.set('trip', 1, 'new-key', tooBigValue)).rejects.toThrow(
'metadata value too large (>65536 bytes)',
);
});
});