@lightupai/polaris 0.0.1

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.
@@ -0,0 +1,365 @@
1
+ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
2
+ import { startServer } from "../src/service/server";
3
+ import type { Sql } from "../src/service/db";
4
+ import type { Server } from "bun";
5
+
6
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
7
+
8
+ let base: string;
9
+ let sql: Sql;
10
+ let stop: () => Promise<void>;
11
+
12
+ beforeAll(async () => {
13
+ const s = await startServer({ port: 0, databaseUrl: DATABASE_URL });
14
+ sql = s.sql;
15
+ stop = s.stop;
16
+ base = `http://localhost:${s.server.port}`;
17
+ });
18
+
19
+ afterAll(async () => {
20
+ await stop();
21
+ });
22
+
23
+ beforeEach(async () => {
24
+ await sql`DROP TABLE IF EXISTS events`;
25
+ await sql`DROP TABLE IF EXISTS sessions`;
26
+ await sql`DROP TABLE IF EXISTS projects`;
27
+ await sql`DROP TABLE IF EXISTS users`;
28
+ await sql`DROP TABLE IF EXISTS orgs`;
29
+ await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
30
+ await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
31
+ await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
32
+ await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
33
+ await sql`CREATE TABLE IF NOT EXISTS events (id UUID PRIMARY KEY, org_id TEXT NOT NULL, project TEXT NOT NULL, session TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, payload JSONB NOT NULL)`;
34
+ await sql`CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)`;
35
+ await sql`CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)`;
36
+ await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
37
+ });
38
+
39
+ async function post(path: string, body: unknown) {
40
+ return fetch(`${base}${path}`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify(body),
44
+ });
45
+ }
46
+
47
+ async function get(path: string) {
48
+ return fetch(`${base}${path}`);
49
+ }
50
+
51
+ // --- Projects ---
52
+
53
+ describe("POST /projects", () => {
54
+ test("creates a project", async () => {
55
+ const res = await post("/projects", { name: "pj" });
56
+ expect(res.status).toBe(201);
57
+ const body = await res.json();
58
+ expect(body.name).toBe("pj");
59
+ expect(body.created_at).toBeDefined();
60
+ });
61
+
62
+ test("409 on duplicate", async () => {
63
+ await post("/projects", { name: "pj" });
64
+ const res = await post("/projects", { name: "pj" });
65
+ expect(res.status).toBe(409);
66
+ });
67
+
68
+ test("400 on missing name", async () => {
69
+ const res = await post("/projects", {});
70
+ expect(res.status).toBe(400);
71
+ });
72
+ });
73
+
74
+ describe("GET /projects/:proj", () => {
75
+ test("returns project metadata", async () => {
76
+ await post("/projects", { name: "pj" });
77
+ const res = await get("/projects/pj");
78
+ expect(res.status).toBe(200);
79
+ const body = await res.json();
80
+ expect(body.name).toBe("pj");
81
+ });
82
+
83
+ test("404 for nonexistent project", async () => {
84
+ const res = await get("/projects/nope");
85
+ expect(res.status).toBe(404);
86
+ });
87
+ });
88
+
89
+ // --- Sessions ---
90
+
91
+ describe("POST /projects/:proj/sessions", () => {
92
+ test("creates a session with driver", async () => {
93
+ await post("/projects", { name: "pj" });
94
+ const res = await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
95
+ expect(res.status).toBe(201);
96
+ const body = await res.json();
97
+ expect(body.name).toBe("fxm");
98
+ expect(body.driver).toBe("user:manu");
99
+ });
100
+
101
+ test("creates a session without driver", async () => {
102
+ await post("/projects", { name: "pj" });
103
+ const res = await post("/projects/pj/sessions", { name: "fxm" });
104
+ expect(res.status).toBe(201);
105
+ const body = await res.json();
106
+ expect(body.driver).toBeNull();
107
+ });
108
+
109
+ test("404 if project doesn't exist", async () => {
110
+ const res = await post("/projects/nope/sessions", { name: "fxm" });
111
+ expect(res.status).toBe(404);
112
+ });
113
+
114
+ test("409 on duplicate session in same project", async () => {
115
+ await post("/projects", { name: "pj" });
116
+ await post("/projects/pj/sessions", { name: "fxm" });
117
+ const res = await post("/projects/pj/sessions", { name: "fxm" });
118
+ expect(res.status).toBe(409);
119
+ });
120
+ });
121
+
122
+ describe("GET /projects/:proj/sessions/:sess", () => {
123
+ test("returns session metadata", async () => {
124
+ await post("/projects", { name: "pj" });
125
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
126
+ const res = await get("/projects/pj/sessions/fxm");
127
+ expect(res.status).toBe(200);
128
+ const body = await res.json();
129
+ expect(body.driver).toBe("user:manu");
130
+ });
131
+
132
+ test("404 for nonexistent session", async () => {
133
+ await post("/projects", { name: "pj" });
134
+ const res = await get("/projects/pj/sessions/nope");
135
+ expect(res.status).toBe(404);
136
+ });
137
+ });
138
+
139
+ // --- Events ---
140
+
141
+ describe("POST /projects/:proj/sessions/:sess/events", () => {
142
+ test("pushes a hook event and returns it", async () => {
143
+ await post("/projects", { name: "pj" });
144
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
145
+ const res = await post("/projects/pj/sessions/fxm/events", {
146
+ sender: "user:manu",
147
+ payload: {
148
+ hook_event_name: "UserPromptSubmit",
149
+ session_id: "s1",
150
+ prompt: "build auth middleware",
151
+ },
152
+ });
153
+ expect(res.status).toBe(201);
154
+ const body = await res.json();
155
+ expect(body.id).toBeDefined();
156
+ expect(body.source).toBe("hook");
157
+ expect(body.payload.prompt).toBe("build auth middleware");
158
+ });
159
+
160
+ test("400 on invalid payload", async () => {
161
+ await post("/projects", { name: "pj" });
162
+ await post("/projects/pj/sessions", { name: "fxm" });
163
+ const res = await post("/projects/pj/sessions/fxm/events", { bad: "data" });
164
+ expect(res.status).toBe(400);
165
+ });
166
+ });
167
+
168
+ // --- Inject ---
169
+
170
+ describe("POST /projects/:proj/sessions/:sess/inject", () => {
171
+ test("injects a message into a session", async () => {
172
+ await post("/projects", { name: "pj" });
173
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
174
+ const res = await post("/projects/pj/sessions/fxm/inject", {
175
+ content: "Use RS256 for auth tokens",
176
+ sender: "user:krishna",
177
+ });
178
+ expect(res.status).toBe(201);
179
+ const body = await res.json();
180
+ expect(body.source).toBe("inject");
181
+ expect(body.payload.content).toBe("Use RS256 for auth tokens");
182
+ expect(body.payload.target).toBe("fxm");
183
+ });
184
+
185
+ test("400 on missing content", async () => {
186
+ await post("/projects", { name: "pj" });
187
+ await post("/projects/pj/sessions", { name: "fxm" });
188
+ const res = await post("/projects/pj/sessions/fxm/inject", { sender: "user:krishna" });
189
+ expect(res.status).toBe(400);
190
+ });
191
+ });
192
+
193
+ // --- Messages ---
194
+
195
+ describe("GET /projects/:proj/messages", () => {
196
+ test("returns all events across sessions", async () => {
197
+ await post("/projects", { name: "pj" });
198
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
199
+ await post("/projects/pj/sessions", { name: "fxk", driver: "user:krishna" });
200
+ await post("/projects/pj/sessions/fxm/events", {
201
+ sender: "user:manu",
202
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s1", prompt: "hello" },
203
+ });
204
+ await post("/projects/pj/sessions/fxk/events", {
205
+ sender: "user:krishna",
206
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s2", prompt: "world" },
207
+ });
208
+
209
+ const res = await get("/projects/pj/messages");
210
+ expect(res.status).toBe(200);
211
+ const body = await res.json();
212
+ expect(body).toHaveLength(2);
213
+ });
214
+ });
215
+
216
+ describe("GET /projects/:proj/sessions/:sess/messages", () => {
217
+ test("returns events for a specific session", async () => {
218
+ await post("/projects", { name: "pj" });
219
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
220
+ await post("/projects/pj/sessions", { name: "fxk", driver: "user:krishna" });
221
+ await post("/projects/pj/sessions/fxm/events", {
222
+ sender: "user:manu",
223
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s1", prompt: "hello" },
224
+ });
225
+ await post("/projects/pj/sessions/fxk/events", {
226
+ sender: "user:krishna",
227
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s2", prompt: "world" },
228
+ });
229
+
230
+ const res = await get("/projects/pj/sessions/fxm/messages");
231
+ expect(res.status).toBe(200);
232
+ const body = await res.json();
233
+ expect(body).toHaveLength(1);
234
+ expect(body[0].payload.prompt).toBe("hello");
235
+ });
236
+ });
237
+
238
+ // --- Handoff & Driver ---
239
+
240
+ describe("handoff and driver claim", () => {
241
+ test("handoff clears driver, claim sets new driver", async () => {
242
+ await post("/projects", { name: "pj" });
243
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
244
+
245
+ // Handoff
246
+ const handoffRes = await post("/projects/pj/sessions/fxm/handoff", {});
247
+ expect(handoffRes.status).toBe(200);
248
+ const handoffBody = await handoffRes.json();
249
+ expect(handoffBody.status).toBe("open");
250
+
251
+ // Verify driver is null
252
+ const sessionRes = await get("/projects/pj/sessions/fxm");
253
+ const sessionBody = await sessionRes.json();
254
+ expect(sessionBody.driver).toBeNull();
255
+
256
+ // Claim
257
+ const claimRes = await post("/projects/pj/sessions/fxm/driver", { driver: "user:krishna" });
258
+ expect(claimRes.status).toBe(200);
259
+ const claimBody = await claimRes.json();
260
+ expect(claimBody.driver).toBe("user:krishna");
261
+
262
+ // Verify driver is set
263
+ const sessionRes2 = await get("/projects/pj/sessions/fxm");
264
+ const sessionBody2 = await sessionRes2.json();
265
+ expect(sessionBody2.driver).toBe("user:krishna");
266
+ });
267
+
268
+ test("handoff fails if no driver", async () => {
269
+ await post("/projects", { name: "pj" });
270
+ await post("/projects/pj/sessions", { name: "fxm" });
271
+ const res = await post("/projects/pj/sessions/fxm/handoff", {});
272
+ expect(res.status).toBe(400);
273
+ });
274
+
275
+ test("claim fails if driver already set", async () => {
276
+ await post("/projects", { name: "pj" });
277
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
278
+ const res = await post("/projects/pj/sessions/fxm/driver", { driver: "user:krishna" });
279
+ expect(res.status).toBe(409);
280
+ });
281
+ });
282
+
283
+ // --- WebSocket ---
284
+
285
+ describe("WebSocket", () => {
286
+ test("project-level WS receives events from all sessions", async () => {
287
+ await post("/projects", { name: "pj" });
288
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
289
+ await post("/projects/pj/sessions", { name: "fxk", driver: "user:krishna" });
290
+
291
+ const ws = new WebSocket(`${base.replace("http", "ws")}/projects/pj/ws`);
292
+ const received: unknown[] = [];
293
+
294
+ await new Promise<void>((resolve) => {
295
+ ws.onopen = () => resolve();
296
+ });
297
+
298
+ ws.onmessage = (e) => received.push(JSON.parse(e.data as string));
299
+
300
+ await post("/projects/pj/sessions/fxm/events", {
301
+ sender: "user:manu",
302
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s1", prompt: "hello from fxm" },
303
+ });
304
+ await post("/projects/pj/sessions/fxk/events", {
305
+ sender: "user:krishna",
306
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s2", prompt: "hello from fxk" },
307
+ });
308
+
309
+ // Give time for messages to arrive
310
+ await new Promise((r) => setTimeout(r, 100));
311
+
312
+ expect(received).toHaveLength(2);
313
+ ws.close();
314
+ });
315
+
316
+ test("session-level WS receives only that session's events", async () => {
317
+ await post("/projects", { name: "pj" });
318
+ await post("/projects/pj/sessions", { name: "fxm", driver: "user:manu" });
319
+ await post("/projects/pj/sessions", { name: "fxk", driver: "user:krishna" });
320
+
321
+ const ws = new WebSocket(`${base.replace("http", "ws")}/projects/pj/sessions/fxm/ws`);
322
+ const received: unknown[] = [];
323
+
324
+ await new Promise<void>((resolve) => {
325
+ ws.onopen = () => resolve();
326
+ });
327
+
328
+ ws.onmessage = (e) => received.push(JSON.parse(e.data as string));
329
+
330
+ await post("/projects/pj/sessions/fxm/events", {
331
+ sender: "user:manu",
332
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s1", prompt: "hello from fxm" },
333
+ });
334
+ await post("/projects/pj/sessions/fxk/events", {
335
+ sender: "user:krishna",
336
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "s2", prompt: "hello from fxk" },
337
+ });
338
+
339
+ await new Promise((r) => setTimeout(r, 100));
340
+
341
+ expect(received).toHaveLength(1);
342
+ expect((received[0] as { session: string }).session).toBe("fxm");
343
+ ws.close();
344
+ });
345
+ });
346
+
347
+ // --- Status ---
348
+
349
+ describe("GET /status", () => {
350
+ test("returns ok", async () => {
351
+ const res = await get("/status");
352
+ expect(res.status).toBe(200);
353
+ const body = await res.json();
354
+ expect(body.ok).toBe(true);
355
+ });
356
+ });
357
+
358
+ // --- 404 ---
359
+
360
+ describe("unknown routes", () => {
361
+ test("returns 404", async () => {
362
+ const res = await get("/nonexistent");
363
+ expect(res.status).toBe(404);
364
+ });
365
+ });
@@ -0,0 +1,240 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ParticipantId,
4
+ HookPayload,
5
+ InjectMessage,
6
+ ReplyMessage,
7
+ PolarisEvent,
8
+ Project,
9
+ Session,
10
+ } from "../src/types";
11
+
12
+ describe("ParticipantId", () => {
13
+ test("accepts valid user IDs", () => {
14
+ expect(ParticipantId.parse("user:manu")).toBe("user:manu");
15
+ expect(ParticipantId.parse("user:krishna")).toBe("user:krishna");
16
+ expect(ParticipantId.parse("user:a1")).toBe("user:a1");
17
+ expect(ParticipantId.parse("user:some-user.name_1")).toBe("user:some-user.name_1");
18
+ });
19
+
20
+ test("accepts valid agent IDs", () => {
21
+ expect(ParticipantId.parse("agent:test-writer")).toBe("agent:test-writer");
22
+ expect(ParticipantId.parse("agent:security-reviewer")).toBe("agent:security-reviewer");
23
+ expect(ParticipantId.parse("agent:dq_checker.v2")).toBe("agent:dq_checker.v2");
24
+ });
25
+
26
+ test("rejects invalid IDs", () => {
27
+ expect(() => ParticipantId.parse("manu")).toThrow();
28
+ expect(() => ParticipantId.parse("")).toThrow();
29
+ expect(() => ParticipantId.parse("foo:bar")).toThrow();
30
+ expect(() => ParticipantId.parse("user:")).toThrow();
31
+ expect(() => ParticipantId.parse("user:CAPS")).toThrow();
32
+ expect(() => ParticipantId.parse("agent:has spaces")).toThrow();
33
+ });
34
+ });
35
+
36
+ describe("HookPayload", () => {
37
+ test("parses UserPromptSubmit", () => {
38
+ const result = HookPayload.parse({
39
+ hook_event_name: "UserPromptSubmit",
40
+ session_id: "abc123",
41
+ prompt: "hello world",
42
+ });
43
+ expect(result.hook_event_name).toBe("UserPromptSubmit");
44
+ expect(result.prompt).toBe("hello world");
45
+ });
46
+
47
+ test("parses Stop", () => {
48
+ const result = HookPayload.parse({
49
+ hook_event_name: "Stop",
50
+ session_id: "abc123",
51
+ stop_response: "Here is my response",
52
+ });
53
+ expect(result.hook_event_name).toBe("Stop");
54
+ expect(result.stop_response).toBe("Here is my response");
55
+ });
56
+
57
+ test("parses PreToolUse", () => {
58
+ const result = HookPayload.parse({
59
+ hook_event_name: "PreToolUse",
60
+ session_id: "abc123",
61
+ tool_name: "Bash",
62
+ tool_input: { command: "ls -la" },
63
+ });
64
+ expect(result.hook_event_name).toBe("PreToolUse");
65
+ expect(result.tool_name).toBe("Bash");
66
+ });
67
+
68
+ test("parses PostToolUse", () => {
69
+ const result = HookPayload.parse({
70
+ hook_event_name: "PostToolUse",
71
+ session_id: "abc123",
72
+ tool_name: "Read",
73
+ tool_input: { file_path: "/tmp/test.txt" },
74
+ tool_result: { content: [{ type: "text", text: "file contents" }] },
75
+ });
76
+ expect(result.hook_event_name).toBe("PostToolUse");
77
+ expect(result.tool_name).toBe("Read");
78
+ });
79
+
80
+ test("rejects unknown hook event names", () => {
81
+ expect(() =>
82
+ HookPayload.parse({
83
+ hook_event_name: "Unknown",
84
+ session_id: "abc123",
85
+ })
86
+ ).toThrow();
87
+ });
88
+
89
+ test("rejects missing required fields", () => {
90
+ expect(() =>
91
+ HookPayload.parse({
92
+ hook_event_name: "UserPromptSubmit",
93
+ session_id: "abc123",
94
+ // missing prompt
95
+ })
96
+ ).toThrow();
97
+ });
98
+ });
99
+
100
+ describe("InjectMessage", () => {
101
+ test("parses valid inject message", () => {
102
+ const result = InjectMessage.parse({
103
+ type: "inject",
104
+ content: "Use RS256 for the auth tokens",
105
+ sender: "user:krishna",
106
+ target: "fxm",
107
+ });
108
+ expect(result.content).toBe("Use RS256 for the auth tokens");
109
+ expect(result.target).toBe("fxm");
110
+ });
111
+
112
+ test("rejects missing target", () => {
113
+ expect(() =>
114
+ InjectMessage.parse({
115
+ type: "inject",
116
+ content: "some advice",
117
+ sender: "user:krishna",
118
+ target: "",
119
+ })
120
+ ).toThrow();
121
+ });
122
+
123
+ test("rejects invalid sender", () => {
124
+ expect(() =>
125
+ InjectMessage.parse({
126
+ type: "inject",
127
+ content: "advice",
128
+ sender: "krishna",
129
+ target: "fxm",
130
+ })
131
+ ).toThrow();
132
+ });
133
+ });
134
+
135
+ describe("ReplyMessage", () => {
136
+ test("parses valid reply", () => {
137
+ const result = ReplyMessage.parse({
138
+ type: "reply",
139
+ content: "Done, switched to RS256",
140
+ sender: "agent:test-writer",
141
+ });
142
+ expect(result.content).toBe("Done, switched to RS256");
143
+ expect(result.in_reply_to).toBeUndefined();
144
+ });
145
+
146
+ test("parses reply with in_reply_to", () => {
147
+ const result = ReplyMessage.parse({
148
+ type: "reply",
149
+ content: "Acknowledged",
150
+ sender: "user:manu",
151
+ in_reply_to: "evt-123",
152
+ });
153
+ expect(result.in_reply_to).toBe("evt-123");
154
+ });
155
+ });
156
+
157
+ describe("PolarisEvent", () => {
158
+ test("parses a full hook event envelope", () => {
159
+ const result = PolarisEvent.parse({
160
+ id: "550e8400-e29b-41d4-a716-446655440000",
161
+ project: "pj",
162
+ session: "fxm",
163
+ timestamp: "2026-06-05T10:00:00.000Z",
164
+ source: "hook",
165
+ sender: "user:manu",
166
+ payload: {
167
+ hook_event_name: "UserPromptSubmit",
168
+ session_id: "abc",
169
+ prompt: "Let's build auth",
170
+ },
171
+ });
172
+ expect(result.source).toBe("hook");
173
+ expect(result.project).toBe("pj");
174
+ });
175
+
176
+ test("parses an inject event envelope", () => {
177
+ const result = PolarisEvent.parse({
178
+ id: "550e8400-e29b-41d4-a716-446655440001",
179
+ project: "pj",
180
+ session: "fxm",
181
+ timestamp: "2026-06-05T10:01:00.000Z",
182
+ source: "inject",
183
+ sender: "user:krishna",
184
+ payload: {
185
+ type: "inject",
186
+ content: "Use JWT RS256",
187
+ sender: "user:krishna",
188
+ target: "fxm",
189
+ },
190
+ });
191
+ expect(result.source).toBe("inject");
192
+ });
193
+
194
+ test("rejects invalid source", () => {
195
+ expect(() =>
196
+ PolarisEvent.parse({
197
+ id: "550e8400-e29b-41d4-a716-446655440002",
198
+ project: "pj",
199
+ session: "fxm",
200
+ timestamp: "2026-06-05T10:00:00.000Z",
201
+ source: "unknown",
202
+ sender: "user:manu",
203
+ payload: {},
204
+ })
205
+ ).toThrow();
206
+ });
207
+ });
208
+
209
+ describe("Project", () => {
210
+ test("parses valid project", () => {
211
+ const result = Project.parse({ name: "pj", created_at: "2026-06-05T10:00:00.000Z" });
212
+ expect(result.name).toBe("pj");
213
+ });
214
+
215
+ test("rejects empty name", () => {
216
+ expect(() => Project.parse({ name: "", created_at: "2026-06-05T10:00:00.000Z" })).toThrow();
217
+ });
218
+ });
219
+
220
+ describe("Session", () => {
221
+ test("parses valid session with driver", () => {
222
+ const result = Session.parse({
223
+ name: "fxm",
224
+ project: "pj",
225
+ driver: "user:manu",
226
+ created_at: "2026-06-05T10:00:00.000Z",
227
+ });
228
+ expect(result.driver).toBe("user:manu");
229
+ });
230
+
231
+ test("parses session with null driver (open for handoff)", () => {
232
+ const result = Session.parse({
233
+ name: "fxm",
234
+ project: "pj",
235
+ driver: null,
236
+ created_at: "2026-06-05T10:00:00.000Z",
237
+ });
238
+ expect(result.driver).toBeNull();
239
+ });
240
+ });