@lightupai/polaris 0.0.76 → 0.0.78

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,209 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ createProject,
6
+ createSession,
7
+ pushEvent,
8
+ addAnnotation,
9
+ listSessionAnnotations,
10
+ listDecisions,
11
+ deleteAnnotation,
12
+ searchEvents,
13
+ type Sql,
14
+ } from "../src/service/db";
15
+ import { Annotation } from "../src/types";
16
+ import type { PolarisEvent } from "../src/types";
17
+
18
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
19
+
20
+ // Unique ids so this file is self-contained against a live Postgres.
21
+ // (annotations rows survive resetTestData, so org-scoped uniqueness matters.)
22
+ const RUN = crypto.randomUUID().slice(0, 8);
23
+ const ORG = `anno-org-${RUN}`;
24
+ const OTHER_ORG = `anno-other-org-${RUN}`;
25
+ const PROJECT = `pj-anno-${RUN}`;
26
+ const PROJECT_B = `pj-anno-b-${RUN}`;
27
+
28
+ let sql: Sql;
29
+
30
+ function makeEvent(overrides: Partial<PolarisEvent> = {}): PolarisEvent {
31
+ return {
32
+ id: crypto.randomUUID(),
33
+ project: PROJECT,
34
+ session: "s1",
35
+ timestamp: new Date().toISOString(),
36
+ source: "hook",
37
+ sender: "user:manu",
38
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "hello" },
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ // Drop and recreate via createDb (mirrors tests/db.test.ts) so the schema —
45
+ // including the additive annotations migration — matches src/service/db.ts exactly.
46
+ sql = await createDb(DATABASE_URL);
47
+ await sql`DROP TABLE IF EXISTS plan_changes`;
48
+ await sql`DROP TABLE IF EXISTS events`;
49
+ await sql`DROP TABLE IF EXISTS sessions`;
50
+ await sql`DROP TABLE IF EXISTS projects`;
51
+ await sql`DROP TABLE IF EXISTS users`;
52
+ await sql`DROP TABLE IF EXISTS orgs`;
53
+ await sql.end();
54
+ sql = await createDb(DATABASE_URL);
55
+ await createOrg(sql, ORG, "Annotations Org");
56
+ await createOrg(sql, OTHER_ORG, "Other Annotations Org");
57
+ await createProject(sql, ORG, PROJECT);
58
+ await createProject(sql, ORG, PROJECT_B);
59
+ await createSession(sql, ORG, PROJECT, "s1", "user:manu");
60
+ await createSession(sql, ORG, PROJECT, "s2", "user:manu");
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await sql.end();
65
+ });
66
+
67
+ describe("annotations CRUD", () => {
68
+ test("add a star and list it back", async () => {
69
+ const event = makeEvent();
70
+ await pushEvent(sql, ORG, event);
71
+ const { id } = await addAnnotation(sql, ORG, {
72
+ event_id: event.id,
73
+ project: PROJECT,
74
+ session: "s1",
75
+ participant_id: "user:manu",
76
+ kind: "star",
77
+ });
78
+ expect(id).toBeDefined();
79
+
80
+ const annotations = await listSessionAnnotations(sql, ORG, PROJECT, "s1");
81
+ const star = annotations.find((a) => a.id === id);
82
+ expect(star).toBeDefined();
83
+ expect(star!.kind).toBe("star");
84
+ expect(star!.event_id).toBe(event.id);
85
+ expect(star!.participant_id).toBe("user:manu");
86
+ expect(star!.value).toBeNull();
87
+ // Round-trips through the shared zod schema
88
+ expect(() => Annotation.parse(star)).not.toThrow();
89
+ });
90
+
91
+ test("add a tag with a value and a session-level decision (no event_id)", async () => {
92
+ const event = makeEvent();
93
+ await pushEvent(sql, ORG, event);
94
+ const tag = await addAnnotation(sql, ORG, {
95
+ event_id: event.id,
96
+ project: PROJECT,
97
+ session: "s1",
98
+ participant_id: "user:krishna",
99
+ kind: "tag",
100
+ value: "auth",
101
+ });
102
+ const decision = await addAnnotation(sql, ORG, {
103
+ project: PROJECT,
104
+ session: "s1",
105
+ kind: "decision",
106
+ value: "Use RS256 for signing",
107
+ });
108
+
109
+ const annotations = await listSessionAnnotations(sql, ORG, PROJECT, "s1");
110
+ const tagged = annotations.find((a) => a.id === tag.id);
111
+ expect(tagged!.kind).toBe("tag");
112
+ expect(tagged!.value).toBe("auth");
113
+
114
+ const decided = annotations.find((a) => a.id === decision.id);
115
+ expect(decided!.kind).toBe("decision");
116
+ expect(decided!.event_id).toBeNull();
117
+ expect(decided!.participant_id).toBeNull();
118
+ });
119
+
120
+ test("listSessionAnnotations is scoped to org, project, and session", async () => {
121
+ const inS2 = await addAnnotation(sql, ORG, { project: PROJECT, session: "s2", kind: "star" });
122
+ const otherOrg = await addAnnotation(sql, OTHER_ORG, { project: PROJECT, session: "s1", kind: "star" });
123
+
124
+ const s1Ids = (await listSessionAnnotations(sql, ORG, PROJECT, "s1")).map((a) => a.id);
125
+ expect(s1Ids).not.toContain(inS2.id);
126
+ expect(s1Ids).not.toContain(otherOrg.id);
127
+
128
+ const s2Ids = (await listSessionAnnotations(sql, ORG, PROJECT, "s2")).map((a) => a.id);
129
+ expect(s2Ids).toContain(inS2.id);
130
+ });
131
+
132
+ test("listDecisions returns only decisions, newest first, with project filter and limit", async () => {
133
+ const d1 = await addAnnotation(sql, ORG, { project: PROJECT, session: "s2", kind: "decision", value: "first" });
134
+ const d2 = await addAnnotation(sql, ORG, { project: PROJECT_B, session: "s9", kind: "decision", value: "second" });
135
+ await addAnnotation(sql, ORG, { project: PROJECT_B, session: "s9", kind: "tag", value: "not-a-decision" });
136
+
137
+ const all = await listDecisions(sql, ORG);
138
+ expect(all.every((a) => a.kind === "decision")).toBe(true);
139
+ const ids = all.map((a) => a.id);
140
+ expect(ids).toContain(d1.id);
141
+ expect(ids).toContain(d2.id);
142
+ // Newest first
143
+ expect(ids.indexOf(d2.id)).toBeLessThan(ids.indexOf(d1.id));
144
+
145
+ const onlyB = await listDecisions(sql, ORG, { project: PROJECT_B });
146
+ expect(onlyB.map((a) => a.id)).toContain(d2.id);
147
+ expect(onlyB.map((a) => a.id)).not.toContain(d1.id);
148
+
149
+ const limited = await listDecisions(sql, ORG, { limit: 1 });
150
+ expect(limited).toHaveLength(1);
151
+ });
152
+
153
+ test("deleteAnnotation removes it; wrong org is a no-op", async () => {
154
+ const { id } = await addAnnotation(sql, ORG, { project: PROJECT, session: "s1", kind: "star" });
155
+
156
+ // Deleting under a different org must not remove it
157
+ await deleteAnnotation(sql, OTHER_ORG, id);
158
+ let ids = (await listSessionAnnotations(sql, ORG, PROJECT, "s1")).map((a) => a.id);
159
+ expect(ids).toContain(id);
160
+
161
+ await deleteAnnotation(sql, ORG, id);
162
+ ids = (await listSessionAnnotations(sql, ORG, PROJECT, "s1")).map((a) => a.id);
163
+ expect(ids).not.toContain(id);
164
+ });
165
+ });
166
+
167
+ describe("searchEvents tag filter", () => {
168
+ // Distinctive vocabulary ("kraken") so full-text matches are unambiguous.
169
+ const taggedInfra = makeEvent({
170
+ timestamp: "2026-06-10T10:00:00.000Z",
171
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "the kraken deployment plan" },
172
+ });
173
+ const taggedBug = makeEvent({
174
+ timestamp: "2026-06-10T10:01:00.000Z",
175
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "kraken retry logic" },
176
+ });
177
+ const untagged = makeEvent({
178
+ timestamp: "2026-06-10T10:02:00.000Z",
179
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "kraken docs" },
180
+ });
181
+
182
+ beforeAll(async () => {
183
+ for (const event of [taggedInfra, taggedBug, untagged]) {
184
+ await pushEvent(sql, ORG, event);
185
+ }
186
+ await addAnnotation(sql, ORG, { event_id: taggedInfra.id, project: PROJECT, session: "s1", kind: "tag", value: "infra" });
187
+ await addAnnotation(sql, ORG, { event_id: taggedBug.id, project: PROJECT, session: "s1", kind: "tag", value: "bug" });
188
+ // A star with the same value must NOT satisfy the tag filter (kind='tag' only)
189
+ await addAnnotation(sql, ORG, { event_id: untagged.id, project: PROJECT, session: "s1", kind: "star", value: "infra" });
190
+ });
191
+
192
+ test("without a tag filter all matches are returned", async () => {
193
+ const { results } = await searchEvents(sql, ORG, { q: "kraken" });
194
+ expect(results.map((r) => r.event.id).sort()).toEqual([taggedInfra.id, taggedBug.id, untagged.id].sort());
195
+ });
196
+
197
+ test("tag filter restricts to events carrying that tag annotation", async () => {
198
+ const infra = await searchEvents(sql, ORG, { q: "kraken", tag: "infra" });
199
+ expect(infra.results.map((r) => r.event.id)).toEqual([taggedInfra.id]);
200
+
201
+ const bug = await searchEvents(sql, ORG, { q: "kraken", tag: "bug" });
202
+ expect(bug.results.map((r) => r.event.id)).toEqual([taggedBug.id]);
203
+ });
204
+
205
+ test("an unknown tag matches nothing", async () => {
206
+ const { results } = await searchEvents(sql, ORG, { q: "kraken", tag: "nope" });
207
+ expect(results).toEqual([]);
208
+ });
209
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ setOrgSlack,
6
+ type Sql,
7
+ } from "../src/service/db";
8
+
9
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
10
+
11
+ // Unique ids so this file is self-contained against a live Postgres.
12
+ const RUN = crypto.randomUUID().slice(0, 8);
13
+ const ORG_A = `bridge-org-a-${RUN}`;
14
+ const ORG_B = `bridge-org-b-${RUN}`;
15
+ const ORG_NO_SLACK = `bridge-org-plain-${RUN}`;
16
+ const ORG_TEAM_ONLY = `bridge-org-teamonly-${RUN}`;
17
+
18
+ // Make sure the schema exists before importing the discovery module (its legacy
19
+ // form runs a query at import time), then resolve the contract export. Until the
20
+ // bridge agent's work is integrated the export is absent and these tests skip.
21
+ {
22
+ const bootstrap = await createDb(DATABASE_URL);
23
+ await bootstrap.end();
24
+ }
25
+ let discoverBridgeOrgs: ((sql: Sql) => Promise<string[]>) | undefined;
26
+ try {
27
+ const mod = (await import("../src/bridge-discover-org")) as Record<string, unknown>;
28
+ if (typeof mod.discoverBridgeOrgs === "function") {
29
+ discoverBridgeOrgs = mod.discoverBridgeOrgs as (sql: Sql) => Promise<string[]>;
30
+ }
31
+ } catch {
32
+ // Import-time side effects failed (legacy script form) — treat as not available.
33
+ }
34
+ if (!discoverBridgeOrgs) {
35
+ console.warn("[bridge-discover.test] discoverBridgeOrgs not exported yet — skipping (expected before bridge-agent integration)");
36
+ }
37
+
38
+ let sql: Sql;
39
+
40
+ beforeAll(async () => {
41
+ sql = await createDb(DATABASE_URL);
42
+ // Eligible: both slack_team_id and slack_bot_token set
43
+ await createOrg(sql, ORG_A, "Bridge Org A");
44
+ await setOrgSlack(sql, ORG_A, `T-A-${RUN}`, `xoxb-a-${RUN}`);
45
+ await createOrg(sql, ORG_B, "Bridge Org B");
46
+ await setOrgSlack(sql, ORG_B, `T-B-${RUN}`, `xoxb-b-${RUN}`);
47
+ // Ineligible: no Slack at all
48
+ await createOrg(sql, ORG_NO_SLACK, "Bridge Org Plain");
49
+ // Ineligible: team id but no bot token
50
+ await createOrg(sql, ORG_TEAM_ONLY, "Bridge Org Team Only");
51
+ await sql`UPDATE orgs SET slack_team_id = ${`T-half-${RUN}`} WHERE id = ${ORG_TEAM_ONLY}`;
52
+ });
53
+
54
+ afterAll(async () => {
55
+ await sql.end();
56
+ });
57
+
58
+ describe("discoverBridgeOrgs", () => {
59
+ test.skipIf(!discoverBridgeOrgs)("returns every org with full Slack credentials (no LIMIT 1)", async () => {
60
+ const orgs = await discoverBridgeOrgs!(sql);
61
+ expect(orgs).toContain(ORG_A);
62
+ expect(orgs).toContain(ORG_B);
63
+ });
64
+
65
+ test.skipIf(!discoverBridgeOrgs)("excludes orgs without complete Slack credentials", async () => {
66
+ const orgs = await discoverBridgeOrgs!(sql);
67
+ expect(orgs).not.toContain(ORG_NO_SLACK);
68
+ expect(orgs).not.toContain(ORG_TEAM_ONLY);
69
+ });
70
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test, afterAll } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Subprocess } from "bun";
6
+
7
+ // Best-effort coverage of the shared-local-secret daemon auth contract:
8
+ // - secret resolved (env POLARIS_DAEMON_SECRET) -> every endpoint requires
9
+ // the `x-polaris-daemon-secret` header, 401 otherwise
10
+ // - no secret resolved -> no auth (test/back-compat mode)
11
+ //
12
+ // The daemon runs as a SUBPROCESS with an isolated HOME so that neither the
13
+ // secret env var nor any real ~/.polaris/config.json can leak into the other
14
+ // test files (which start in-process daemons without auth).
15
+ // Until the daemon agent's enforcement is integrated, the enforcement tests
16
+ // detect "no 401" and skip with a warning instead of failing.
17
+
18
+ const DAEMON_PATH = join(import.meta.dir, "..", "src", "daemon", "daemon.ts");
19
+ const SECRET = `test-secret-${crypto.randomUUID()}`;
20
+
21
+ const cleanups: Array<() => Promise<void> | void> = [];
22
+
23
+ function baseEnv(home: string, overrides: Record<string, string>): Record<string, string> {
24
+ const env = { ...(process.env as Record<string, string>) };
25
+ delete env.POLARIS_DAEMON_SECRET;
26
+ return {
27
+ ...env,
28
+ HOME: home,
29
+ // /status and /register never call upstream; point it nowhere just in case.
30
+ POLARIS_SERVICE_URL: "http://127.0.0.1:9",
31
+ POLARIS_AUTH_TOKEN: "",
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ async function spawnDaemon(overrides: Record<string, string>): Promise<{ proc: Subprocess; url: string } | null> {
37
+ const home = await mkdtemp(join(tmpdir(), "polaris-daemon-auth-"));
38
+ cleanups.push(() => rm(home, { recursive: true, force: true }));
39
+ for (let attempt = 0; attempt < 3; attempt++) {
40
+ const port = 20000 + Math.floor(Math.random() * 20000);
41
+ const proc = Bun.spawn([process.execPath, DAEMON_PATH], {
42
+ env: baseEnv(home, { POLARIS_DAEMON_PORT: String(port), ...overrides }),
43
+ stdout: "ignore",
44
+ stderr: "ignore",
45
+ });
46
+ const url = `http://127.0.0.1:${port}`;
47
+ const deadline = Date.now() + 8000;
48
+ while (Date.now() < deadline) {
49
+ if (proc.exitCode !== null) break; // crashed (port collision) — retry on a new port
50
+ try {
51
+ await fetch(`${url}/status`); // any HTTP response (200 or 401) means it's up
52
+ cleanups.push(() => { proc.kill(); });
53
+ return { proc, url };
54
+ } catch {
55
+ await Bun.sleep(100);
56
+ }
57
+ }
58
+ proc.kill();
59
+ }
60
+ return null;
61
+ }
62
+
63
+ const secured = await spawnDaemon({ POLARIS_DAEMON_SECRET: SECRET });
64
+ let enforced = false;
65
+ if (!secured) {
66
+ console.warn("[daemon-auth.test] could not start a daemon subprocess — skipping daemon auth tests");
67
+ } else {
68
+ enforced = (await fetch(`${secured.url}/status`)).status === 401;
69
+ if (!enforced) {
70
+ console.warn("[daemon-auth.test] secret enforcement not detected — skipping (expected before daemon-agent integration)");
71
+ }
72
+ }
73
+
74
+ const unsecured = await spawnDaemon({});
75
+
76
+ afterAll(async () => {
77
+ for (const cleanup of cleanups.reverse()) {
78
+ try { await cleanup(); } catch { /* best-effort */ }
79
+ }
80
+ });
81
+
82
+ describe("daemon auth (shared local secret)", () => {
83
+ test.skipIf(!enforced)("rejects requests without the secret header", async () => {
84
+ expect((await fetch(`${secured!.url}/status`)).status).toBe(401);
85
+ expect((await fetch(`${secured!.url}/status/cc-anything`)).status).toBe(401);
86
+ const post = await fetch(`${secured!.url}/register`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ ccSessionId: "cc-auth-1" }),
90
+ });
91
+ expect(post.status).toBe(401);
92
+ });
93
+
94
+ test.skipIf(!enforced)("rejects a wrong secret", async () => {
95
+ const res = await fetch(`${secured!.url}/status`, {
96
+ headers: { "x-polaris-daemon-secret": "wrong-secret" },
97
+ });
98
+ expect(res.status).toBe(401);
99
+ });
100
+
101
+ test.skipIf(!enforced)("accepts requests carrying the correct secret", async () => {
102
+ const res = await fetch(`${secured!.url}/status`, {
103
+ headers: { "x-polaris-daemon-secret": SECRET },
104
+ });
105
+ expect(res.status).toBe(200);
106
+ const body = await res.json();
107
+ expect(body.ok).toBe(true);
108
+
109
+ const post = await fetch(`${secured!.url}/register`, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json", "x-polaris-daemon-secret": SECRET },
112
+ body: JSON.stringify({ ccSessionId: "cc-auth-2" }),
113
+ });
114
+ expect(post.status).toBe(200);
115
+ });
116
+
117
+ test.skipIf(!unsecured)("without a resolved secret the daemon requires no auth (back-compat)", async () => {
118
+ const res = await fetch(`${unsecured!.url}/status`);
119
+ expect(res.status).toBe(200);
120
+ const body = await res.json();
121
+ expect(body.ok).toBe(true);
122
+ });
123
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ createProject,
6
+ createSession,
7
+ pushEvent,
8
+ getEventById,
9
+ getSessionEvents,
10
+ type Sql,
11
+ } from "../src/service/db";
12
+ import type { PolarisEvent } from "../src/types";
13
+
14
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
15
+
16
+ // Unique ids so this file is self-contained against a live Postgres.
17
+ const RUN = crypto.randomUUID().slice(0, 8);
18
+ const ORG = `lookup-org-${RUN}`;
19
+ const PROJECT = `pj-lookup-${RUN}`;
20
+
21
+ let sql: Sql;
22
+
23
+ function makeEvent(overrides: Partial<PolarisEvent> = {}): PolarisEvent {
24
+ return {
25
+ id: crypto.randomUUID(),
26
+ project: PROJECT,
27
+ session: "s1",
28
+ timestamp: new Date().toISOString(),
29
+ source: "hook",
30
+ sender: "user:manu",
31
+ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "hello lookup" },
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ // Drop and recreate via createDb (mirrors tests/db.test.ts) so the schema
38
+ // matches src/service/db.ts exactly.
39
+ sql = await createDb(DATABASE_URL);
40
+ await sql`DROP TABLE IF EXISTS plan_changes`;
41
+ await sql`DROP TABLE IF EXISTS events`;
42
+ await sql`DROP TABLE IF EXISTS sessions`;
43
+ await sql`DROP TABLE IF EXISTS projects`;
44
+ await sql`DROP TABLE IF EXISTS users`;
45
+ await sql`DROP TABLE IF EXISTS orgs`;
46
+ await sql.end();
47
+ sql = await createDb(DATABASE_URL);
48
+ await createOrg(sql, ORG, "Lookup Org");
49
+ await createProject(sql, ORG, PROJECT);
50
+ await createSession(sql, ORG, PROJECT, "s1", "user:manu");
51
+ });
52
+
53
+ afterAll(async () => {
54
+ await sql.end();
55
+ });
56
+
57
+ describe("pushEvent + getEventById", () => {
58
+ test("a pushed event is retrievable by id with project name and org_id", async () => {
59
+ const event = makeEvent();
60
+ await pushEvent(sql, ORG, event);
61
+
62
+ const fetched = await getEventById(sql, event.id);
63
+ expect(fetched).not.toBeNull();
64
+ expect(fetched!.id).toBe(event.id);
65
+ expect(fetched!.project).toBe(PROJECT);
66
+ expect(fetched!.session).toBe("s1");
67
+ expect(fetched!.source).toBe("hook");
68
+ expect(fetched!.sender).toBe("user:manu");
69
+ expect(fetched!.payload).toEqual(event.payload);
70
+ expect(fetched!.org_id).toBe(ORG);
71
+ });
72
+
73
+ test("returns null for an unknown uuid and for a malformed id", async () => {
74
+ expect(await getEventById(sql, crypto.randomUUID())).toBeNull();
75
+ // NOTIFY payloads are untyped strings — a non-uuid must not throw
76
+ expect(await getEventById(sql, "not-a-uuid")).toBeNull();
77
+ });
78
+
79
+ test("pushEvent into a nonexistent project is a silent no-op", async () => {
80
+ const event = makeEvent({ project: `pj-missing-${RUN}` });
81
+ await pushEvent(sql, ORG, event); // must not throw
82
+ expect(await getEventById(sql, event.id)).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe("LISTEN/NOTIFY realtime backbone", () => {
87
+ test("pushEvent NOTIFYs 'polaris_event' with the event id", async () => {
88
+ // Dedicated second connection for LISTEN, mirroring how server.ts/bridge subscribe.
89
+ const listener = await createDb(DATABASE_URL);
90
+ const received: string[] = [];
91
+ try {
92
+ await listener.listen("polaris_event", (payload) => {
93
+ received.push(payload);
94
+ });
95
+
96
+ const event = makeEvent({ payload: { hook_event_name: "UserPromptSubmit", session_id: "cc-s", prompt: "notify me" } });
97
+ await pushEvent(sql, ORG, event);
98
+
99
+ const deadline = Date.now() + 5000;
100
+ while (!received.includes(event.id) && Date.now() < deadline) {
101
+ await Bun.sleep(25);
102
+ }
103
+ expect(received).toContain(event.id);
104
+
105
+ // The notified id resolves to the full event via getEventById (the listener's own connection)
106
+ const fetched = await getEventById(listener, event.id);
107
+ expect(fetched).not.toBeNull();
108
+ expect(fetched!.org_id).toBe(ORG);
109
+ } finally {
110
+ await listener.end({ timeout: 5 });
111
+ }
112
+ }, 15000);
113
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
+ import {
3
+ createDb,
4
+ createOrg,
5
+ createProject,
6
+ createSession,
7
+ getSession,
8
+ listSessions,
9
+ setSessionLabel,
10
+ type Sql,
11
+ } from "../src/service/db";
12
+
13
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
14
+
15
+ // Unique ids so this file is self-contained against a live Postgres.
16
+ const RUN = crypto.randomUUID().slice(0, 8);
17
+ const ORG = `label-org-${RUN}`;
18
+ const PROJECT = `pj-label-${RUN}`;
19
+ const SESSION = "fx-label";
20
+ const OTHER_SESSION = "fx-other";
21
+
22
+ let sql: Sql;
23
+
24
+ beforeAll(async () => {
25
+ // Drop and recreate via createDb (NOT tests/helpers.ts resetTestData, which builds
26
+ // a sessions table without the label column) so the additive label migration applies.
27
+ sql = await createDb(DATABASE_URL);
28
+ await sql`DROP TABLE IF EXISTS plan_changes`;
29
+ await sql`DROP TABLE IF EXISTS events`;
30
+ await sql`DROP TABLE IF EXISTS sessions`;
31
+ await sql`DROP TABLE IF EXISTS projects`;
32
+ await sql`DROP TABLE IF EXISTS users`;
33
+ await sql`DROP TABLE IF EXISTS orgs`;
34
+ await sql.end();
35
+ sql = await createDb(DATABASE_URL);
36
+ await createOrg(sql, ORG, "Label Org");
37
+ await createProject(sql, ORG, PROJECT);
38
+ });
39
+
40
+ afterAll(async () => {
41
+ await sql.end();
42
+ });
43
+
44
+ describe("session labels", () => {
45
+ test("createSession and getSession default to a null label", async () => {
46
+ const created = await createSession(sql, ORG, PROJECT, SESSION, "user:manu");
47
+ expect(created.label).toBeNull();
48
+
49
+ const fetched = await getSession(sql, ORG, PROJECT, SESSION);
50
+ expect(fetched).not.toBeNull();
51
+ expect(fetched!.label).toBeNull();
52
+ });
53
+
54
+ test("setSessionLabel then getSession returns the label", async () => {
55
+ await setSessionLabel(sql, ORG, PROJECT, SESSION, "Auth refactor");
56
+ const session = await getSession(sql, ORG, PROJECT, SESSION);
57
+ expect(session!.label).toBe("Auth refactor");
58
+ });
59
+
60
+ test("listSessions returns the label", async () => {
61
+ const sessions = await listSessions(sql, ORG, PROJECT);
62
+ const labeled = sessions.find((s) => s.name === SESSION);
63
+ expect(labeled).toBeDefined();
64
+ expect(labeled!.label).toBe("Auth refactor");
65
+ });
66
+
67
+ test("relabeling overwrites the previous label", async () => {
68
+ await setSessionLabel(sql, ORG, PROJECT, SESSION, "Token refresh work");
69
+ const session = await getSession(sql, ORG, PROJECT, SESSION);
70
+ expect(session!.label).toBe("Token refresh work");
71
+ });
72
+
73
+ test("labels are scoped to the target session", async () => {
74
+ await createSession(sql, ORG, PROJECT, OTHER_SESSION, null);
75
+ const other = await getSession(sql, ORG, PROJECT, OTHER_SESSION);
76
+ expect(other!.label).toBeNull();
77
+
78
+ const labeled = await getSession(sql, ORG, PROJECT, SESSION);
79
+ expect(labeled!.label).toBe("Token refresh work");
80
+ });
81
+
82
+ test("setSessionLabel on an unknown session is a silent no-op", async () => {
83
+ // Per the db contract: UPDATE matches 0 rows, no throw.
84
+ await setSessionLabel(sql, ORG, PROJECT, "fx-nonexistent", "ignored");
85
+ expect(await getSession(sql, ORG, PROJECT, "fx-nonexistent")).toBeNull();
86
+
87
+ const untouched = await getSession(sql, ORG, PROJECT, SESSION);
88
+ expect(untouched!.label).toBe("Token refresh work");
89
+ });
90
+ });