@pairlobby/fixtures 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +93 -0
- package/README.md +5 -0
- package/dist/admin-contract.d.ts +3 -0
- package/dist/admin-contract.d.ts.map +1 -0
- package/dist/admin-contract.js +63 -0
- package/dist/admin-contract.js.map +1 -0
- package/dist/auto-close-contract.d.ts +3 -0
- package/dist/auto-close-contract.d.ts.map +1 -0
- package/dist/auto-close-contract.js +204 -0
- package/dist/auto-close-contract.js.map +1 -0
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +688 -0
- package/dist/contract.js.map +1 -0
- package/dist/fake-agent.d.ts +41 -0
- package/dist/fake-agent.d.ts.map +1 -0
- package/dist/fake-agent.js +90 -0
- package/dist/fake-agent.js.map +1 -0
- package/dist/faulty-store.d.ts +19 -0
- package/dist/faulty-store.d.ts.map +1 -0
- package/dist/faulty-store.js +54 -0
- package/dist/faulty-store.js.map +1 -0
- package/dist/group-contract.d.ts +3 -0
- package/dist/group-contract.d.ts.map +1 -0
- package/dist/group-contract.js +237 -0
- package/dist/group-contract.js.map +1 -0
- package/dist/handover-samples.d.ts +3 -0
- package/dist/handover-samples.d.ts.map +1 -0
- package/dist/handover-samples.js +23 -0
- package/dist/handover-samples.js.map +1 -0
- package/dist/harness.d.ts +635 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +114 -0
- package/dist/harness.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/interrupt-contract.d.ts +3 -0
- package/dist/interrupt-contract.d.ts.map +1 -0
- package/dist/interrupt-contract.js +142 -0
- package/dist/interrupt-contract.js.map +1 -0
- package/dist/memory-store.d.ts +39 -0
- package/dist/memory-store.d.ts.map +1 -0
- package/dist/memory-store.js +175 -0
- package/dist/memory-store.js.map +1 -0
- package/dist/redemption-contract.d.ts +3 -0
- package/dist/redemption-contract.d.ts.map +1 -0
- package/dist/redemption-contract.js +94 -0
- package/dist/redemption-contract.js.map +1 -0
- package/dist/rejoin-contract.d.ts +3 -0
- package/dist/rejoin-contract.d.ts.map +1 -0
- package/dist/rejoin-contract.js +117 -0
- package/dist/rejoin-contract.js.map +1 -0
- package/dist/request-contract.d.ts +3 -0
- package/dist/request-contract.d.ts.map +1 -0
- package/dist/request-contract.js +215 -0
- package/dist/request-contract.js.map +1 -0
- package/package.json +40 -0
package/dist/contract.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
//! The room contract. Every storage adapter must satisfy it; a test that passes
|
|
2
|
+
//! here states what the Durable Object and the Node/SQLite server must also do.
|
|
3
|
+
//!
|
|
4
|
+
//! Call `runRoomContract` from a test file, passing a factory for the store
|
|
5
|
+
//! under test.
|
|
6
|
+
import { DEFAULT_ROOM_POLICY, ProtocolError, newCredential, newId } from '@pairlobby/protocol';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
8
|
+
import { runRequestContract } from './request-contract.js';
|
|
9
|
+
import { runRejoinContract } from './rejoin-contract.js';
|
|
10
|
+
import { runGroupContract } from './group-contract.js';
|
|
11
|
+
import { runAdminContract } from './admin-contract.js';
|
|
12
|
+
import { runAutoCloseContract } from './auto-close-contract.js';
|
|
13
|
+
import { runInterruptContract } from './interrupt-contract.js';
|
|
14
|
+
import { FakeAgent } from './fake-agent.js';
|
|
15
|
+
import { RoomHarness, fixedClock } from './harness.js';
|
|
16
|
+
import { sampleHandover } from './handover-samples.js';
|
|
17
|
+
async function expectError(code, run) {
|
|
18
|
+
try {
|
|
19
|
+
await run();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
expect(error).toBeInstanceOf(ProtocolError);
|
|
23
|
+
expect(error.code).toBe(code);
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`expected ${code} but the call succeeded`);
|
|
27
|
+
}
|
|
28
|
+
export function runRoomContract(label, makeStore) {
|
|
29
|
+
runRequestContract(label, makeStore);
|
|
30
|
+
runRejoinContract(label, makeStore);
|
|
31
|
+
runGroupContract(label, makeStore);
|
|
32
|
+
runAdminContract(label, makeStore);
|
|
33
|
+
runAutoCloseContract(label, makeStore);
|
|
34
|
+
runInterruptContract(label, makeStore);
|
|
35
|
+
describe(label, () => {
|
|
36
|
+
const opened = [];
|
|
37
|
+
function track(harness) {
|
|
38
|
+
opened.push(harness);
|
|
39
|
+
return harness;
|
|
40
|
+
}
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
while (opened.length > 0)
|
|
43
|
+
opened.pop().dispose();
|
|
44
|
+
});
|
|
45
|
+
describe('room lifecycle', () => {
|
|
46
|
+
let server;
|
|
47
|
+
let alice;
|
|
48
|
+
let bob;
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
server = track(new RoomHarness(makeStore()));
|
|
51
|
+
alice = new FakeAgent(server, 'claude', { capabilities: { deliverUnsolicited: true, cancelTurn: true, cancelTool: false, runtime: 'claude-code' } });
|
|
52
|
+
bob = new FakeAgent(server, 'codex', { capabilities: { deliverUnsolicited: false, cancelTurn: false, cancelTool: false, runtime: 'codex-cli' } });
|
|
53
|
+
const created = await alice.create('my-project');
|
|
54
|
+
await bob.join(created.inviteCode);
|
|
55
|
+
});
|
|
56
|
+
test('test_join_produces_distinct_identities_and_cursors', async () => {
|
|
57
|
+
expect(alice.participantId).not.toBe(bob.participantId);
|
|
58
|
+
expect(alice.credential).not.toBe(bob.credential);
|
|
59
|
+
expect(alice.sessionId).not.toBe(bob.sessionId);
|
|
60
|
+
await alice.say('hello codex', bob.participantId);
|
|
61
|
+
expect(await bob.poll()).toHaveLength(1);
|
|
62
|
+
expect(bob.cursor).toBeGreaterThan(0);
|
|
63
|
+
expect(alice.cursor).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
test('test_only_addressed_events_reach_an_agent_inbox', async () => {
|
|
66
|
+
await alice.say('thinking out loud');
|
|
67
|
+
await alice.say('codex, take this', bob.participantId);
|
|
68
|
+
const addressed = await bob.poll();
|
|
69
|
+
expect(addressed).toHaveLength(1);
|
|
70
|
+
expect(addressed[0].type).toBe('message');
|
|
71
|
+
// Room-wide chatter is still readable by every member; it just does not activate them.
|
|
72
|
+
const page = await server.read(bob.credential, 0);
|
|
73
|
+
expect(page.events.filter((event) => event.type === 'message')).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
test('test_two_sessions_of_one_runtime_stay_isolated', async () => {
|
|
76
|
+
const secondClaude = new FakeAgent(server, 'claude', {});
|
|
77
|
+
await secondClaude.join(await server.mintInvite('member'));
|
|
78
|
+
expect(secondClaude.participantId).not.toBe(alice.participantId);
|
|
79
|
+
expect(secondClaude.sessionId).not.toBe(alice.sessionId);
|
|
80
|
+
await alice.say('for the first session only', alice.participantId);
|
|
81
|
+
expect(await secondClaude.poll()).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('guest access', () => {
|
|
85
|
+
let server;
|
|
86
|
+
let alice;
|
|
87
|
+
let controller;
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
server = track(new RoomHarness(makeStore()));
|
|
90
|
+
alice = new FakeAgent(server, 'claude');
|
|
91
|
+
const created = await alice.create('guest-room');
|
|
92
|
+
controller = created.controllerCredential;
|
|
93
|
+
});
|
|
94
|
+
const guest = { displayName: 'hugo', kind: 'human' };
|
|
95
|
+
test('test_a_closed_room_refuses_a_guest_who_knows_its_id', async () => {
|
|
96
|
+
await expectError('unauthorized', () => server.joinAsGuest(guest, newCredential('participant')));
|
|
97
|
+
});
|
|
98
|
+
test('test_an_open_room_admits_a_guest_with_no_invite_code', async () => {
|
|
99
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
100
|
+
const joined = await server.joinAsGuest(guest, newCredential('participant'));
|
|
101
|
+
expect(joined.role).toBe('guest');
|
|
102
|
+
expect((await server.snapshot(controller)).policy.joinPolicy).toBe('open_to_guests');
|
|
103
|
+
});
|
|
104
|
+
test('test_a_guest_can_read_the_whole_transcript', async () => {
|
|
105
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
106
|
+
await alice.say('something before the guest arrived');
|
|
107
|
+
const credential = newCredential('participant');
|
|
108
|
+
await server.joinAsGuest(guest, credential);
|
|
109
|
+
const page = await server.read(credential, 0);
|
|
110
|
+
expect(page.events.some((event) => event.type === 'message')).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test('test_a_guest_cannot_write_in_any_form', async () => {
|
|
113
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
114
|
+
const credential = newCredential('participant');
|
|
115
|
+
const joined = await server.joinAsGuest(guest, credential);
|
|
116
|
+
await expectError('unauthorized', () => server.send(credential, { type: 'message', payload: { text: 'hello', priority: 'normal' }, idempotencyKey: newId('event') }));
|
|
117
|
+
await expectError('unauthorized', () => server.send(credential, {
|
|
118
|
+
type: 'handover.offered',
|
|
119
|
+
payload: { handoverId: newId('handover'), revision: 1, document: sampleHandover() },
|
|
120
|
+
idempotencyKey: newId('event'),
|
|
121
|
+
recipientId: alice.participantId
|
|
122
|
+
}));
|
|
123
|
+
await expectError('unauthorized', () => server.send(credential, {
|
|
124
|
+
type: 'control.ack',
|
|
125
|
+
payload: { targetParticipantId: joined.participantId, revision: 1, outcome: 'resumed' },
|
|
126
|
+
idempotencyKey: newId('event')
|
|
127
|
+
}));
|
|
128
|
+
await expectError('unauthorized', () => server.control(credential, alice.participantId, true));
|
|
129
|
+
await expectError('unauthorized', () => server.revoke(credential, alice.participantId));
|
|
130
|
+
await expectError('unauthorized', () => server.close(credential));
|
|
131
|
+
await expectError('unauthorized', () => server.rename(credential, 'hijacked'));
|
|
132
|
+
await expectError('unauthorized', () => server.setExpiry(credential, Date.now() + 60_000));
|
|
133
|
+
await expectError('unauthorized', () => server.setJoinPolicy(credential, 'invite_only'));
|
|
134
|
+
});
|
|
135
|
+
test('test_a_guest_cannot_widen_the_room', async () => {
|
|
136
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
137
|
+
const credential = newCredential('participant');
|
|
138
|
+
await server.joinAsGuest(guest, credential);
|
|
139
|
+
await expectError('unauthorized', () => server.mintInviteAs(credential));
|
|
140
|
+
});
|
|
141
|
+
test('test_a_guest_may_leave', async () => {
|
|
142
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
143
|
+
const credential = newCredential('participant');
|
|
144
|
+
const joined = await server.joinAsGuest(guest, credential);
|
|
145
|
+
await server.leave(credential);
|
|
146
|
+
expect((await server.snapshot(controller)).participants.find((participant) => participant.participantId === joined.participantId).left).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
test('test_closing_the_room_again_stops_new_guests', async () => {
|
|
149
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
150
|
+
await server.joinAsGuest(guest, newCredential('participant'));
|
|
151
|
+
await server.setJoinPolicy(controller, 'invite_only');
|
|
152
|
+
await expectError('unauthorized', () => server.joinAsGuest({ displayName: 'latecomer', kind: 'human' }, newCredential('participant')));
|
|
153
|
+
});
|
|
154
|
+
test('test_only_the_controller_can_open_a_room', async () => {
|
|
155
|
+
await expectError('unauthorized', () => server.setJoinPolicy(alice.credential, 'open_to_guests'));
|
|
156
|
+
});
|
|
157
|
+
test('test_invitations_admit_members_until_the_owner_chooses_observers', async () => {
|
|
158
|
+
const before = await server.snapshot(controller);
|
|
159
|
+
expect(before.inviteRoleSupported).toBe(true);
|
|
160
|
+
expect(before.policy.inviteRole ?? 'member').toBe('member');
|
|
161
|
+
await expectError('unauthorized', () => server.setInviteRole(alice.credential, 'guest'));
|
|
162
|
+
await server.setInviteRole(controller, 'guest');
|
|
163
|
+
expect((await server.snapshot(controller)).policy.inviteRole).toBe('guest');
|
|
164
|
+
await expectError('invalid_request', () => server.setInviteRole(controller, 'guest'));
|
|
165
|
+
const changed = (await server.read(alice.credential, 0)).events.find((event) => event.type === 'room.access_changed');
|
|
166
|
+
expect(changed?.type === 'room.access_changed' && changed.payload).toEqual({ joinPolicy: 'invite_only', inviteRole: 'guest' });
|
|
167
|
+
await server.setInviteRole(controller, 'member');
|
|
168
|
+
expect((await server.snapshot(controller)).policy.inviteRole).toBe('member');
|
|
169
|
+
});
|
|
170
|
+
test('test_opening_is_recorded_in_history', async () => {
|
|
171
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
172
|
+
const page = await server.read(alice.credential, 0);
|
|
173
|
+
const changed = page.events.find((event) => event.type === 'room.access_changed');
|
|
174
|
+
expect(changed?.type === 'room.access_changed' && changed.payload.joinPolicy).toBe('open_to_guests');
|
|
175
|
+
});
|
|
176
|
+
test('test_guests_count_against_the_participant_cap', async () => {
|
|
177
|
+
await server.setJoinPolicy(controller, 'open_to_guests');
|
|
178
|
+
for (let index = 1; index < DEFAULT_ROOM_POLICY.maxParticipants; index += 1) {
|
|
179
|
+
await server.joinAsGuest({ displayName: `guest-${index}`, kind: 'human' }, newCredential('participant'));
|
|
180
|
+
}
|
|
181
|
+
await expectError('participant_limit_reached', () => server.joinAsGuest({ displayName: 'one-too-many', kind: 'human' }, newCredential('participant')));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
describe('invitations by handle', () => {
|
|
185
|
+
let server;
|
|
186
|
+
let alice;
|
|
187
|
+
let controller;
|
|
188
|
+
beforeEach(async () => {
|
|
189
|
+
server = track(new RoomHarness(makeStore()));
|
|
190
|
+
alice = new FakeAgent(server, 'claude');
|
|
191
|
+
controller = (await alice.create('invited-room')).controllerCredential;
|
|
192
|
+
});
|
|
193
|
+
test('test_inviting_and_withdrawing_by_handle_are_recorded_for_the_room', async () => {
|
|
194
|
+
const invited = await server.recordInvitation(controller, { handle: 'maria', state: 'invited', role: 'guest', agents: 1 });
|
|
195
|
+
await server.recordInvitation(controller, { handle: 'maria', state: 'withdrawn' });
|
|
196
|
+
const recorded = (await server.read(alice.credential, 0)).events.filter((event) => event.type === 'room.invitation_changed');
|
|
197
|
+
expect(recorded.map((event) => event.type === 'room.invitation_changed' && event.payload)).toEqual([{ handle: 'maria', state: 'invited', role: 'guest', agents: 1 }, { handle: 'maria', state: 'withdrawn' }]);
|
|
198
|
+
expect(recorded[0].eventId).toBe(invited.eventId);
|
|
199
|
+
});
|
|
200
|
+
test('test_only_an_owner_or_admin_records_an_invitation', async () => {
|
|
201
|
+
await expectError('unauthorized', () => server.recordInvitation(alice.credential, { handle: 'maria', state: 'invited' }));
|
|
202
|
+
expect((await server.read(alice.credential, 0)).events.some((event) => event.type === 'room.invitation_changed')).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
describe('local network join', () => {
|
|
206
|
+
let server;
|
|
207
|
+
let alice;
|
|
208
|
+
let controller;
|
|
209
|
+
const neighbour = { displayName: 'tower', kind: 'human' };
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
server = track(new RoomHarness(makeStore()));
|
|
212
|
+
alice = new FakeAgent(server, 'claude');
|
|
213
|
+
controller = (await alice.create('local-room')).controllerCredential;
|
|
214
|
+
});
|
|
215
|
+
test('test_a_room_is_closed_to_the_local_network_by_default', async () => {
|
|
216
|
+
expect((await server.snapshot(controller)).policy.localJoin ?? false).toBe(false);
|
|
217
|
+
await expectError('unauthorized', () => server.joinOnLocalNetwork(neighbour, newCredential('participant')));
|
|
218
|
+
});
|
|
219
|
+
test('test_an_open_local_room_admits_a_member_who_can_speak', async () => {
|
|
220
|
+
await server.setLocalJoin(controller, true);
|
|
221
|
+
const credential = newCredential('participant');
|
|
222
|
+
const joined = await server.joinOnLocalNetwork(neighbour, credential);
|
|
223
|
+
expect(joined.role).toBe('member');
|
|
224
|
+
await server.send(credential, { type: 'message', payload: { text: 'hello from the tower', priority: 'normal' }, idempotencyKey: newId('event') });
|
|
225
|
+
expect((await server.read(alice.credential, 0)).events.some((event) => event.type === 'message' && event.payload.text === 'hello from the tower')).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
test('test_a_local_join_retried_with_the_same_credential_is_one_membership', async () => {
|
|
228
|
+
await server.setLocalJoin(controller, true);
|
|
229
|
+
const credential = newCredential('participant');
|
|
230
|
+
const first = await server.joinOnLocalNetwork(neighbour, credential);
|
|
231
|
+
const again = await server.joinOnLocalNetwork(neighbour, credential);
|
|
232
|
+
expect(again.participantId).toBe(first.participantId);
|
|
233
|
+
expect(again.replayed).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
test('test_only_an_owner_or_admin_opens_a_room_to_the_local_network', async () => {
|
|
236
|
+
await expectError('unauthorized', () => server.setLocalJoin(alice.credential, true));
|
|
237
|
+
await server.setLocalJoin(controller, true);
|
|
238
|
+
await expectError('invalid_request', () => server.setLocalJoin(controller, true));
|
|
239
|
+
const changed = (await server.read(alice.credential, 0)).events.find((event) => event.type === 'room.local_join_changed');
|
|
240
|
+
expect(changed?.type === 'room.local_join_changed' && changed.payload).toEqual({ localJoin: true });
|
|
241
|
+
});
|
|
242
|
+
test('test_a_lock_or_turning_it_off_refuses_new_local_joins', async () => {
|
|
243
|
+
await server.setLocalJoin(controller, true);
|
|
244
|
+
const stays = newCredential('participant');
|
|
245
|
+
await server.joinOnLocalNetwork(neighbour, stays);
|
|
246
|
+
await server.setLocked(controller, true);
|
|
247
|
+
await expectError('room_locked', () => server.joinOnLocalNetwork({ displayName: 'late', kind: 'human' }, newCredential('participant')));
|
|
248
|
+
await server.setLocked(controller, false);
|
|
249
|
+
await server.setLocalJoin(controller, false);
|
|
250
|
+
await expectError('unauthorized', () => server.joinOnLocalNetwork({ displayName: 'later', kind: 'human' }, newCredential('participant')));
|
|
251
|
+
expect((await server.snapshot(stays)).participants.some((participant) => participant.displayName === 'tower' && !participant.left)).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe('invite seats', () => {
|
|
255
|
+
let server;
|
|
256
|
+
let alice;
|
|
257
|
+
beforeEach(async () => {
|
|
258
|
+
server = track(new RoomHarness(makeStore()));
|
|
259
|
+
alice = new FakeAgent(server, 'claude');
|
|
260
|
+
await alice.create('seat-room');
|
|
261
|
+
});
|
|
262
|
+
test('test_a_code_is_refused_while_its_occupant_is_still_in_the_room', async () => {
|
|
263
|
+
const code = await server.mintInvite('member');
|
|
264
|
+
await new FakeAgent(server, 'hugo').join(code);
|
|
265
|
+
await expectError('invite_already_redeemed', () => new FakeAgent(server, 'someone-else').join(code));
|
|
266
|
+
});
|
|
267
|
+
test('test_a_code_works_again_once_its_occupant_leaves', async () => {
|
|
268
|
+
const code = await server.mintInvite('member');
|
|
269
|
+
const first = new FakeAgent(server, 'hugo');
|
|
270
|
+
await first.join(code);
|
|
271
|
+
await server.leave(first.credential);
|
|
272
|
+
const second = new FakeAgent(server, 'hugo');
|
|
273
|
+
await second.join(code);
|
|
274
|
+
expect(second.participantId).not.toBe(first.participantId);
|
|
275
|
+
const snapshot = await server.snapshot(second.credential);
|
|
276
|
+
expect(snapshot.participants.filter((participant) => !participant.left && !participant.revoked)).toHaveLength(2);
|
|
277
|
+
});
|
|
278
|
+
test('test_leaving_frees_the_seat_for_the_participant_cap_too', async () => {
|
|
279
|
+
const code = await server.mintInvite('member');
|
|
280
|
+
const first = new FakeAgent(server, 'hugo');
|
|
281
|
+
await first.join(code);
|
|
282
|
+
await server.leave(first.credential);
|
|
283
|
+
const snapshot = await server.snapshot(first.credential);
|
|
284
|
+
expect(snapshot.participants.find((participant) => participant.participantId === first.participantId).left).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
// The occupancy check and the reservation have to be one atomic step, or
|
|
287
|
+
// every racer sees the same departed occupant and they all claim the seat.
|
|
288
|
+
test('test_racing_claims_on_one_vacated_seat_admit_exactly_one', async () => {
|
|
289
|
+
const code = await server.mintInvite('member');
|
|
290
|
+
const first = new FakeAgent(server, 'hugo');
|
|
291
|
+
await first.join(code);
|
|
292
|
+
await server.leave(first.credential);
|
|
293
|
+
const claimants = Array.from({ length: 8 }, (_, index) => new FakeAgent(server, `claimant-${index}`));
|
|
294
|
+
const results = await Promise.allSettled(claimants.map((claimant) => claimant.join(code)));
|
|
295
|
+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
296
|
+
const snapshot = await server.snapshot(first.credential);
|
|
297
|
+
expect(snapshot.participants.filter((participant) => !participant.left && !participant.revoked)).toHaveLength(2);
|
|
298
|
+
});
|
|
299
|
+
test('test_a_revoked_participants_seat_stays_shut', async () => {
|
|
300
|
+
const code = await server.mintInvite('member');
|
|
301
|
+
const evicted = new FakeAgent(server, 'hugo');
|
|
302
|
+
await evicted.join(code);
|
|
303
|
+
await server.revoke(server.controller(), evicted.participantId);
|
|
304
|
+
// Removal is deliberate; reusing the code that admitted them must not undo it.
|
|
305
|
+
await expectError('invite_already_redeemed', () => new FakeAgent(server, 'hugo-again').join(code));
|
|
306
|
+
});
|
|
307
|
+
test('test_a_single_use_code_stays_spent_after_its_holder_leaves', async () => {
|
|
308
|
+
const code = await server.mintInviteOnce();
|
|
309
|
+
const first = new FakeAgent(server, 'hugo');
|
|
310
|
+
await first.join(code);
|
|
311
|
+
await server.leave(first.credential);
|
|
312
|
+
await expectError('invite_already_redeemed', () => new FakeAgent(server, 'hugo-again').join(code));
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
describe('idempotency and retries', () => {
|
|
316
|
+
test('test_repeated_key_with_identical_content_returns_the_original_event', async () => {
|
|
317
|
+
const server = track(new RoomHarness(makeStore()));
|
|
318
|
+
const alice = new FakeAgent(server, 'claude');
|
|
319
|
+
await alice.create('retry-room');
|
|
320
|
+
const key = newId('event');
|
|
321
|
+
const first = await server.send(alice.credential, { type: 'message', payload: { text: 'once', priority: 'normal' }, idempotencyKey: key });
|
|
322
|
+
const second = await server.send(alice.credential, { type: 'message', payload: { text: 'once', priority: 'normal' }, idempotencyKey: key });
|
|
323
|
+
expect(second.deduplicated).toBe(true);
|
|
324
|
+
expect(second.event.seq).toBe(first.event.seq);
|
|
325
|
+
expect(second.event.eventId).toBe(first.event.eventId);
|
|
326
|
+
const page = await server.read(alice.credential, 0);
|
|
327
|
+
expect(page.events.filter((event) => event.type === 'message')).toHaveLength(1);
|
|
328
|
+
});
|
|
329
|
+
test('test_repeated_key_with_different_content_is_a_conflict', async () => {
|
|
330
|
+
const server = track(new RoomHarness(makeStore()));
|
|
331
|
+
const alice = new FakeAgent(server, 'claude');
|
|
332
|
+
await alice.create('retry-room');
|
|
333
|
+
const key = newId('event');
|
|
334
|
+
await server.send(alice.credential, { type: 'message', payload: { text: 'first', priority: 'normal' }, idempotencyKey: key });
|
|
335
|
+
await expectError('idempotency_conflict', () => server.send(alice.credential, { type: 'message', payload: { text: 'different', priority: 'normal' }, idempotencyKey: key }));
|
|
336
|
+
});
|
|
337
|
+
test('test_repeated_redemption_of_one_attempt_creates_one_member', async () => {
|
|
338
|
+
const server = track(new RoomHarness(makeStore()));
|
|
339
|
+
const alice = new FakeAgent(server, 'claude');
|
|
340
|
+
const created = await alice.create('join-room');
|
|
341
|
+
const attemptId = newId('attempt');
|
|
342
|
+
const credential = `plp_${newId('room')}`;
|
|
343
|
+
const identity = { displayName: 'codex', kind: 'agent' };
|
|
344
|
+
const first = await server.redeemInvite(created.inviteCode, identity, attemptId, credential);
|
|
345
|
+
const second = await server.redeemInvite(created.inviteCode, identity, attemptId, credential);
|
|
346
|
+
expect(second.participantId).toBe(first.participantId);
|
|
347
|
+
expect(second.replayed).toBe(true);
|
|
348
|
+
const snapshot = await server.snapshot(created.controllerCredential);
|
|
349
|
+
expect(snapshot.participants).toHaveLength(2);
|
|
350
|
+
});
|
|
351
|
+
test('test_a_different_attempt_cannot_claim_a_used_invite', async () => {
|
|
352
|
+
const server = track(new RoomHarness(makeStore()));
|
|
353
|
+
const alice = new FakeAgent(server, 'claude');
|
|
354
|
+
const created = await alice.create('join-room');
|
|
355
|
+
await server.redeemInvite(created.inviteCode, { displayName: 'codex', kind: 'agent' }, newId('attempt'), `plp_${newId('room')}`);
|
|
356
|
+
await expectError('invite_already_redeemed', () => server.redeemInvite(created.inviteCode, { displayName: 'stranger', kind: 'agent' }, newId('attempt'), `plp_${newId('room')}`));
|
|
357
|
+
});
|
|
358
|
+
test('test_an_invite_does_not_expire_unless_it_was_given_a_deadline', async () => {
|
|
359
|
+
const clock = fixedClock();
|
|
360
|
+
const server = track(new RoomHarness(makeStore(), clock));
|
|
361
|
+
const alice = new FakeAgent(server, 'claude');
|
|
362
|
+
const created = await alice.create('join-room');
|
|
363
|
+
clock.advance(365 * 24 * 60 * 60 * 1000);
|
|
364
|
+
const joined = await server.redeemInvite(created.inviteCode, { displayName: 'codex', kind: 'agent' }, newId('attempt'), `plp_${newId('room')}`);
|
|
365
|
+
expect(joined.replayed).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
test('test_an_invite_given_a_deadline_stops_working_after_it', async () => {
|
|
368
|
+
const clock = fixedClock();
|
|
369
|
+
const server = track(new RoomHarness(makeStore(), clock));
|
|
370
|
+
const alice = new FakeAgent(server, 'claude');
|
|
371
|
+
await alice.create('join-room');
|
|
372
|
+
const code = await server.mintExpiringInvite(10 * 60 * 1000);
|
|
373
|
+
clock.advance(10 * 60 * 1000 + 1);
|
|
374
|
+
await expectError('invite_expired', () => server.redeemInvite(code, { displayName: 'codex', kind: 'agent' }, newId('attempt'), `plp_${newId('room')}`));
|
|
375
|
+
});
|
|
376
|
+
test('test_a_deadline_only_gates_the_first_use_not_the_seat', async () => {
|
|
377
|
+
const clock = fixedClock();
|
|
378
|
+
const server = track(new RoomHarness(makeStore(), clock));
|
|
379
|
+
const alice = new FakeAgent(server, 'claude');
|
|
380
|
+
await alice.create('join-room');
|
|
381
|
+
const code = await server.mintExpiringInvite(10 * 60 * 1000);
|
|
382
|
+
const first = new FakeAgent(server, 'hugo');
|
|
383
|
+
await first.join(code);
|
|
384
|
+
await server.leave(first.credential);
|
|
385
|
+
// The seat reopens even though the original deadline has passed: the
|
|
386
|
+
// code was already claimed, so the clock has done its job.
|
|
387
|
+
clock.advance(10 * 60 * 1000 + 1);
|
|
388
|
+
await new FakeAgent(server, 'hugo').join(code);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
describe('handover', () => {
|
|
392
|
+
let server;
|
|
393
|
+
let alice;
|
|
394
|
+
let bob;
|
|
395
|
+
let controller;
|
|
396
|
+
beforeEach(async () => {
|
|
397
|
+
server = track(new RoomHarness(makeStore()));
|
|
398
|
+
alice = new FakeAgent(server, 'claude');
|
|
399
|
+
bob = new FakeAgent(server, 'codex');
|
|
400
|
+
const created = await alice.create('handover-room');
|
|
401
|
+
controller = created.controllerCredential;
|
|
402
|
+
await bob.join(created.inviteCode);
|
|
403
|
+
});
|
|
404
|
+
test('test_recipient_accepts_the_offered_revision', async () => {
|
|
405
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
406
|
+
await bob.acceptHandover(handoverId, 1);
|
|
407
|
+
const page = await server.read(bob.credential, 0);
|
|
408
|
+
expect(page.events.some((event) => event.type === 'handover.accepted')).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
test('test_a_non_recipient_cannot_accept', async () => {
|
|
411
|
+
const carol = new FakeAgent(server, 'gemini');
|
|
412
|
+
await carol.join(await server.mintInvite('member'));
|
|
413
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
414
|
+
await expectError('unauthorized', () => carol.acceptHandover(handoverId, 1));
|
|
415
|
+
});
|
|
416
|
+
test('test_a_sender_cannot_hand_over_to_itself', async () => {
|
|
417
|
+
await expectError('invalid_request', () => alice.offerHandover(alice.participantId, sampleHandover()));
|
|
418
|
+
});
|
|
419
|
+
test('test_accepting_a_revision_that_is_not_current_fails', async () => {
|
|
420
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
421
|
+
const error = await expectError('stale_handover_revision', () => bob.acceptHandover(handoverId, 2));
|
|
422
|
+
expect(error.details.currentRevision).toBe(1);
|
|
423
|
+
});
|
|
424
|
+
test('test_decline_then_amend_then_accept_the_new_revision', async () => {
|
|
425
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
426
|
+
await bob.declineHandover(handoverId, 1, 'the dirty changes are not reachable from here');
|
|
427
|
+
await alice.offerHandover(bob.participantId, sampleHandover({ nextAction: 'Pull branch feature/room-core first' }), handoverId, 2);
|
|
428
|
+
// The stale revision can never be accepted, before or after the amendment.
|
|
429
|
+
await expectError('stale_handover_revision', () => bob.acceptHandover(handoverId, 1));
|
|
430
|
+
await bob.acceptHandover(handoverId, 2);
|
|
431
|
+
});
|
|
432
|
+
test('test_a_declined_revision_cannot_later_be_accepted', async () => {
|
|
433
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
434
|
+
await bob.declineHandover(handoverId, 1, 'the dirty changes are not reachable from here');
|
|
435
|
+
// Reversing a decline would leave the sender believing the work was refused.
|
|
436
|
+
await expectError('handover_already_resolved', () => bob.acceptHandover(handoverId, 1));
|
|
437
|
+
await expectError('handover_already_resolved', () => bob.declineHandover(handoverId, 1));
|
|
438
|
+
});
|
|
439
|
+
test('test_an_accepted_handover_cannot_be_amended_or_re_resolved', async () => {
|
|
440
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
441
|
+
await bob.acceptHandover(handoverId, 1);
|
|
442
|
+
await expectError('handover_already_resolved', () => alice.offerHandover(bob.participantId, sampleHandover(), handoverId, 2));
|
|
443
|
+
await expectError('handover_already_resolved', () => bob.acceptHandover(handoverId, 1));
|
|
444
|
+
});
|
|
445
|
+
test('test_an_amendment_by_another_participant_is_refused', async () => {
|
|
446
|
+
const handoverId = await alice.offerHandover(bob.participantId, sampleHandover());
|
|
447
|
+
await expectError('unauthorized', () => bob.offerHandover(alice.participantId, sampleHandover(), handoverId, 2));
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
describe('human control', () => {
|
|
451
|
+
let server;
|
|
452
|
+
let alice;
|
|
453
|
+
let bob;
|
|
454
|
+
let controller;
|
|
455
|
+
beforeEach(async () => {
|
|
456
|
+
server = track(new RoomHarness(makeStore()));
|
|
457
|
+
alice = new FakeAgent(server, 'claude', { capabilities: { deliverUnsolicited: true, cancelTurn: true, cancelTool: false } });
|
|
458
|
+
bob = new FakeAgent(server, 'codex', { capabilities: { deliverUnsolicited: false, cancelTurn: false, cancelTool: false } });
|
|
459
|
+
const created = await alice.create('control-room');
|
|
460
|
+
controller = created.controllerCredential;
|
|
461
|
+
await bob.join(created.inviteCode);
|
|
462
|
+
});
|
|
463
|
+
test('test_pause_is_acknowledged_with_what_actually_happened', async () => {
|
|
464
|
+
await server.control(controller, alice.participantId, true);
|
|
465
|
+
await alice.poll();
|
|
466
|
+
const snapshot = await server.snapshot(controller);
|
|
467
|
+
const view = snapshot.participants.find((participant) => participant.participantId === alice.participantId);
|
|
468
|
+
expect(view.paused).toBe(true);
|
|
469
|
+
expect(view.acknowledgedOutcome).toBe('current_turn_cancelled');
|
|
470
|
+
});
|
|
471
|
+
test('test_an_adapter_without_cancellation_reports_paused_between_turns', async () => {
|
|
472
|
+
await server.control(controller, bob.participantId, true);
|
|
473
|
+
await bob.poll();
|
|
474
|
+
const snapshot = await server.snapshot(controller);
|
|
475
|
+
expect(snapshot.participants.find((participant) => participant.participantId === bob.participantId).acknowledgedOutcome).toBe('paused_between_turns');
|
|
476
|
+
});
|
|
477
|
+
test('test_a_member_cannot_pause_another_member', async () => {
|
|
478
|
+
await expectError('unauthorized', () => server.control(alice.credential, bob.participantId, true));
|
|
479
|
+
});
|
|
480
|
+
test('test_a_participant_cannot_acknowledge_control_for_someone_else', async () => {
|
|
481
|
+
await server.control(controller, bob.participantId, true);
|
|
482
|
+
await expectError('unauthorized', () => server.send(alice.credential, {
|
|
483
|
+
type: 'control.ack',
|
|
484
|
+
payload: { targetParticipantId: bob.participantId, revision: 1, outcome: 'paused_between_turns' },
|
|
485
|
+
idempotencyKey: newId('event')
|
|
486
|
+
}));
|
|
487
|
+
});
|
|
488
|
+
test('test_a_late_acknowledgement_does_not_overwrite_a_newer_revision', async () => {
|
|
489
|
+
const pause = await server.control(controller, bob.participantId, true);
|
|
490
|
+
const pauseRevision = pause.type === 'control.pause' ? pause.payload.revision : 0;
|
|
491
|
+
await server.control(controller, bob.participantId, false);
|
|
492
|
+
await bob.poll();
|
|
493
|
+
const afterResume = await server.snapshot(controller);
|
|
494
|
+
expect(afterResume.participants.find((participant) => participant.participantId === bob.participantId).acknowledgedOutcome).toBe('resumed');
|
|
495
|
+
// The stale pause acknowledgement arrives now. It is recorded as history but must not win.
|
|
496
|
+
await server.send(bob.credential, {
|
|
497
|
+
type: 'control.ack',
|
|
498
|
+
payload: { targetParticipantId: bob.participantId, revision: pauseRevision, outcome: 'paused_between_turns' },
|
|
499
|
+
idempotencyKey: newId('event')
|
|
500
|
+
});
|
|
501
|
+
const snapshot = await server.snapshot(controller);
|
|
502
|
+
const view = snapshot.participants.find((participant) => participant.participantId === bob.participantId);
|
|
503
|
+
expect(view.paused).toBe(false);
|
|
504
|
+
expect(view.acknowledgedOutcome).toBe('resumed');
|
|
505
|
+
// Two acknowledgements from the poll plus the late one: all three are history.
|
|
506
|
+
const page = await server.read(controller, 0);
|
|
507
|
+
expect(page.events.filter((event) => event.type === 'control.ack')).toHaveLength(3);
|
|
508
|
+
expect(page.events.at(-1).type).toBe('control.ack');
|
|
509
|
+
});
|
|
510
|
+
test('test_acknowledging_a_revision_the_server_never_issued_fails', async () => {
|
|
511
|
+
await server.control(controller, bob.participantId, true);
|
|
512
|
+
await expectError('invalid_request', () => server.send(bob.credential, {
|
|
513
|
+
type: 'control.ack',
|
|
514
|
+
payload: { targetParticipantId: bob.participantId, revision: 99, outcome: 'paused_between_turns' },
|
|
515
|
+
idempotencyKey: newId('event')
|
|
516
|
+
}));
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
describe('revocation, closure, and expiry', () => {
|
|
520
|
+
let server;
|
|
521
|
+
let alice;
|
|
522
|
+
let bob;
|
|
523
|
+
let controller;
|
|
524
|
+
beforeEach(async () => {
|
|
525
|
+
server = track(new RoomHarness(makeStore(), fixedClock()));
|
|
526
|
+
alice = new FakeAgent(server, 'claude');
|
|
527
|
+
bob = new FakeAgent(server, 'codex');
|
|
528
|
+
const created = await alice.create('revoke-room');
|
|
529
|
+
controller = created.controllerCredential;
|
|
530
|
+
await bob.join(created.inviteCode);
|
|
531
|
+
});
|
|
532
|
+
test('test_a_revoked_participant_can_neither_read_nor_write', async () => {
|
|
533
|
+
await server.revoke(controller, bob.participantId);
|
|
534
|
+
await expectError('participant_revoked', () => bob.say('still here?'));
|
|
535
|
+
await expectError('participant_revoked', () => server.read(bob.credential, 0));
|
|
536
|
+
});
|
|
537
|
+
test('test_revoking_twice_is_refused', async () => {
|
|
538
|
+
await server.revoke(controller, bob.participantId);
|
|
539
|
+
await expectError('idempotency_conflict', () => server.revoke(controller, bob.participantId));
|
|
540
|
+
});
|
|
541
|
+
test('test_the_controller_can_rename_a_room_and_history_records_it', async () => {
|
|
542
|
+
await server.rename(controller, 'invite recovery work');
|
|
543
|
+
expect((await server.snapshot(controller)).name).toBe('invite recovery work');
|
|
544
|
+
const page = await server.read(alice.credential, 0);
|
|
545
|
+
const renamed = page.events.find((event) => event.type === 'room.renamed');
|
|
546
|
+
expect(renamed).toBeDefined();
|
|
547
|
+
expect(renamed.type === 'room.renamed' && renamed.payload.previousName).toBe('revoke-room');
|
|
548
|
+
});
|
|
549
|
+
test('test_a_member_cannot_rename_a_room', async () => {
|
|
550
|
+
await expectError('unauthorized', () => server.rename(alice.credential, 'hijacked'));
|
|
551
|
+
});
|
|
552
|
+
test('test_renaming_to_the_same_name_is_refused', async () => {
|
|
553
|
+
await expectError('invalid_request', () => server.rename(controller, 'revoke-room'));
|
|
554
|
+
});
|
|
555
|
+
test('test_a_room_is_invite_only_unless_told_otherwise', async () => {
|
|
556
|
+
expect((await server.snapshot(controller)).policy.joinPolicy).toBe('invite_only');
|
|
557
|
+
});
|
|
558
|
+
test('test_a_closed_room_cannot_be_renamed', async () => {
|
|
559
|
+
await server.close(controller);
|
|
560
|
+
await expectError('room_closed', () => server.rename(controller, 'too late'));
|
|
561
|
+
});
|
|
562
|
+
test('test_a_closed_room_rejects_new_work_but_stays_readable', async () => {
|
|
563
|
+
const unusedInvite = await server.mintInvite('member');
|
|
564
|
+
await server.close(controller);
|
|
565
|
+
await expectError('room_closed', () => alice.say('one more'));
|
|
566
|
+
await expectError('room_closed', () => server.mintInvite('member'));
|
|
567
|
+
await expectError('room_closed', () => server.redeemInvite(unusedInvite, { displayName: 'latecomer', kind: 'agent' }, newId('attempt'), `plp_${newId('room')}`));
|
|
568
|
+
const page = await server.read(alice.credential, 0);
|
|
569
|
+
expect(page.events.some((event) => event.type === 'room.closed')).toBe(true);
|
|
570
|
+
expect((await server.snapshot(controller)).lifecycle).toBe('closed');
|
|
571
|
+
});
|
|
572
|
+
test('test_a_closed_room_becomes_unreadable_once_its_export_window_ends', async () => {
|
|
573
|
+
const windowClock = fixedClock();
|
|
574
|
+
const closing = track(new RoomHarness(makeStore(), windowClock));
|
|
575
|
+
const carol = new FakeAgent(closing, 'claude');
|
|
576
|
+
const created = await carol.create('closing-room');
|
|
577
|
+
await closing.close(created.controllerCredential);
|
|
578
|
+
windowClock.advance(DEFAULT_ROOM_POLICY.exportWindowMs - 1);
|
|
579
|
+
expect((await closing.read(carol.credential, 0)).events.length).toBeGreaterThan(0);
|
|
580
|
+
windowClock.advance(2);
|
|
581
|
+
await expectError('room_expired', () => closing.read(carol.credential, 0));
|
|
582
|
+
});
|
|
583
|
+
test('test_a_room_does_not_expire_unless_told_to', async () => {
|
|
584
|
+
const clock = fixedClock();
|
|
585
|
+
const permanent = track(new RoomHarness(makeStore(), clock));
|
|
586
|
+
const carol = new FakeAgent(permanent, 'claude');
|
|
587
|
+
const created = await carol.create('permanent-room');
|
|
588
|
+
expect((await permanent.snapshot(created.controllerCredential)).expiresAt).toBeNull();
|
|
589
|
+
clock.advance(365 * 24 * 60 * 60 * 1000);
|
|
590
|
+
await carol.say('still here a year later');
|
|
591
|
+
});
|
|
592
|
+
test('test_expiry_is_enforced_on_reads_and_writes_before_any_cleanup', async () => {
|
|
593
|
+
const clock = fixedClock();
|
|
594
|
+
const expiring = track(new RoomHarness(makeStore(), clock));
|
|
595
|
+
const carol = new FakeAgent(expiring, 'claude');
|
|
596
|
+
const created = await carol.create('expiring-room');
|
|
597
|
+
await expiring.setExpiry(created.controllerCredential, clock.now() + 60_000);
|
|
598
|
+
clock.advance(60_001);
|
|
599
|
+
await expectError('room_expired', () => carol.say('anyone there?'));
|
|
600
|
+
await expectError('room_expired', () => expiring.read(carol.credential, 0));
|
|
601
|
+
await expectError('room_expired', () => expiring.snapshot(created.controllerCredential));
|
|
602
|
+
});
|
|
603
|
+
test('test_expiry_can_be_lifted_again', async () => {
|
|
604
|
+
const clock = fixedClock();
|
|
605
|
+
const harness = track(new RoomHarness(makeStore(), clock));
|
|
606
|
+
const carol = new FakeAgent(harness, 'claude');
|
|
607
|
+
const created = await carol.create('reprieve-room');
|
|
608
|
+
await harness.setExpiry(created.controllerCredential, clock.now() + 60_000);
|
|
609
|
+
await harness.setExpiry(created.controllerCredential, null);
|
|
610
|
+
clock.advance(120_000);
|
|
611
|
+
await carol.say('reprieved');
|
|
612
|
+
expect((await harness.snapshot(created.controllerCredential)).expiresAt).toBeNull();
|
|
613
|
+
});
|
|
614
|
+
test('test_only_the_controller_can_change_expiry', async () => {
|
|
615
|
+
await expectError('unauthorized', () => server.setExpiry(alice.credential, Date.now() + 60_000));
|
|
616
|
+
});
|
|
617
|
+
test('test_an_expiry_in_the_past_is_refused', async () => {
|
|
618
|
+
const clock = fixedClock();
|
|
619
|
+
const harness = track(new RoomHarness(makeStore(), clock));
|
|
620
|
+
const carol = new FakeAgent(harness, 'claude');
|
|
621
|
+
const created = await carol.create('past-room');
|
|
622
|
+
await expectError('invalid_request', () => harness.setExpiry(created.controllerCredential, clock.now() - 1));
|
|
623
|
+
});
|
|
624
|
+
test('test_changing_expiry_is_recorded_in_history', async () => {
|
|
625
|
+
const deadline = Date.now() + 3_600_000;
|
|
626
|
+
await server.setExpiry(controller, deadline);
|
|
627
|
+
const page = await server.read(alice.credential, 0);
|
|
628
|
+
const changed = page.events.find((event) => event.type === 'room.expiry_changed');
|
|
629
|
+
expect(changed?.type === 'room.expiry_changed' && changed.payload.expiresAt).toBe(deadline);
|
|
630
|
+
});
|
|
631
|
+
test('test_an_unknown_credential_is_unauthorized', async () => {
|
|
632
|
+
await expectError('unauthorized', () => server.read('plp_not-a-real-credential', 0));
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
describe('bounds', () => {
|
|
636
|
+
test('test_an_oversized_payload_is_refused', async () => {
|
|
637
|
+
const server = track(new RoomHarness(makeStore()));
|
|
638
|
+
const alice = new FakeAgent(server, 'claude');
|
|
639
|
+
await alice.create('bounds-room');
|
|
640
|
+
await expectError('payload_too_large', () => alice.say('x'.repeat(DEFAULT_ROOM_POLICY.maxEventPayloadBytes + 1)));
|
|
641
|
+
});
|
|
642
|
+
test('test_the_participant_limit_is_enforced', async () => {
|
|
643
|
+
const server = track(new RoomHarness(makeStore()));
|
|
644
|
+
const alice = new FakeAgent(server, 'claude');
|
|
645
|
+
const created = await alice.create('crowded-room');
|
|
646
|
+
for (let index = 1; index < DEFAULT_ROOM_POLICY.maxParticipants; index += 1) {
|
|
647
|
+
await new FakeAgent(server, `agent-${index}`).join(await server.mintInvite('member'));
|
|
648
|
+
}
|
|
649
|
+
const overflowCode = await server.mintInvite('member');
|
|
650
|
+
await expectError('participant_limit_reached', () => new FakeAgent(server, 'one-too-many').join(overflowCode));
|
|
651
|
+
expect((await server.snapshot(created.controllerCredential)).participants).toHaveLength(DEFAULT_ROOM_POLICY.maxParticipants);
|
|
652
|
+
});
|
|
653
|
+
test('test_quota_exhaustion_bounds_ordinary_writes_but_leaves_control_and_close_usable', async () => {
|
|
654
|
+
const server = track(new RoomHarness(makeStore()));
|
|
655
|
+
const alice = new FakeAgent(server, 'claude');
|
|
656
|
+
const bob = new FakeAgent(server, 'codex');
|
|
657
|
+
const created = await server.createRoom('tiny-room', { displayName: 'claude', kind: 'agent' }, { ...DEFAULT_ROOM_POLICY, maxRetainedEvents: 4 });
|
|
658
|
+
alice.participantId = created.participantId;
|
|
659
|
+
alice.credential = created.participantCredential;
|
|
660
|
+
await bob.join(created.inviteCode);
|
|
661
|
+
// participant.joined x2 already counted; two more ordinary writes reach the cap.
|
|
662
|
+
await alice.say('one');
|
|
663
|
+
await alice.say('two');
|
|
664
|
+
await expectError('quota_exceeded', () => alice.say('three'));
|
|
665
|
+
// Control, revocation, and closure stay usable at quota.
|
|
666
|
+
await server.control(created.controllerCredential, bob.participantId, true);
|
|
667
|
+
await bob.poll();
|
|
668
|
+
expect((await server.snapshot(created.controllerCredential)).participants.find((participant) => participant.participantId === bob.participantId).paused).toBe(true);
|
|
669
|
+
await server.revoke(created.controllerCredential, bob.participantId);
|
|
670
|
+
await server.close(created.controllerCredential);
|
|
671
|
+
expect((await server.read(created.controllerCredential, 0)).events.some((event) => event.type === 'room.closed')).toBe(true);
|
|
672
|
+
});
|
|
673
|
+
test('test_a_cursor_older_than_retained_history_returns_a_gap', async () => {
|
|
674
|
+
const server = track(new RoomHarness(makeStore()));
|
|
675
|
+
const alice = new FakeAgent(server, 'claude');
|
|
676
|
+
await alice.create('gap-room');
|
|
677
|
+
for (let index = 0; index < 5; index += 1)
|
|
678
|
+
await alice.say(`message ${index}`);
|
|
679
|
+
server.dropHistoryBefore(4);
|
|
680
|
+
const error = await expectError('cursor_gap', () => server.read(alice.credential, 1));
|
|
681
|
+
expect(error.details.earliestAvailableSeq).toBe(4);
|
|
682
|
+
const fresh = await server.read(alice.credential, 4);
|
|
683
|
+
expect(fresh.events.length).toBeGreaterThan(0);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
//# sourceMappingURL=contract.js.map
|