CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
personal-semantic.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| ee7e577 | 1 | /** |
| 2 | * Tests for src/lib/personal-semantic.ts — cross-repo semantic search | |
| 3 | * over the union of repos a user has access to. | |
| 4 | * | |
| 5 | * Layered: | |
| 6 | * | |
| 7 | * 1. Pure helpers / opt-in gate (no DB-specific shapes). | |
| 8 | * Covered by short-circuit assertions on missing inputs. | |
| 9 | * | |
| 10 | * 2. DB-backed pipeline — gated on HAS_DB. | |
| 11 | * Critical security tests live here: | |
| 12 | * - Refusal when the opt-in flag is OFF (the privacy contract). | |
| 13 | * - Owned-repo hits surface; non-owned repos that the user has | |
| 14 | * no collaborator row for are NEVER surfaced. | |
| 15 | * - Cross-user leak prevention: a hit indexed for user-B's | |
| 16 | * private repo must not appear in user-A's results. | |
| 17 | * | |
| 18 | * The embedder seam from semantic-index is used to make vectors | |
| 19 | * deterministic so the test asserts on file_path / repo_name shape rather | |
| 20 | * than on cosine-score ordering (which depends on pgvector being | |
| 21 | * available). | |
| 22 | */ | |
| 23 | ||
| 24 | import { | |
| 25 | afterAll, | |
| 26 | afterEach, | |
| 27 | beforeAll, | |
| 28 | beforeEach, | |
| 29 | describe, | |
| 30 | expect, | |
| 31 | it, | |
| 32 | } from "bun:test"; | |
| 33 | import { join } from "path"; | |
| 34 | import { mkdir, rm } from "fs/promises"; | |
| 35 | import { randomBytes } from "crypto"; | |
| 36 | ||
| 37 | import { | |
| 38 | isPersonalSemanticEnabled, | |
| 39 | searchAcrossAllReposForUser, | |
| 40 | searchPersonalSemantic, | |
| 41 | setPersonalSemanticEnabled, | |
| 42 | } from "../lib/personal-semantic"; | |
| 43 | import { | |
| 44 | __setEmbedderForTests, | |
| 45 | EMBEDDING_DIM, | |
| 46 | } from "../lib/semantic-index"; | |
| 47 | import { initBareRepo, getRepoPath } from "../git/repository"; | |
| 48 | ||
| 49 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 50 | ||
| 51 | const TEST_REPOS = join( | |
| 52 | import.meta.dir, | |
| 53 | "../../.test-repos-personal-semantic-" + Date.now() | |
| 54 | ); | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | process.env.GIT_REPOS_PATH = TEST_REPOS; | |
| 58 | process.env.GLUECRON_SEMANTIC_CACHE_DIR = join(TEST_REPOS, "_cache"); | |
| 59 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 60 | await mkdir(TEST_REPOS, { recursive: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | afterAll(async () => { | |
| 64 | __setEmbedderForTests(null); | |
| 65 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | beforeEach(() => { | |
| 69 | __setEmbedderForTests(null); | |
| 70 | }); | |
| 71 | ||
| 72 | afterEach(() => { | |
| 73 | __setEmbedderForTests(null); | |
| 74 | }); | |
| 75 | ||
| 76 | // --------------------------------------------------------------------------- | |
| 77 | // 1. Pure short-circuits — no DB. | |
| 78 | // --------------------------------------------------------------------------- | |
| 79 | ||
| 80 | describe("personal-semantic — short-circuits", () => { | |
| 81 | it("returns [] for empty userId", async () => { | |
| 82 | const out = await searchPersonalSemantic({ userId: "", query: "foo" }); | |
| 83 | expect(out).toEqual([]); | |
| 84 | }); | |
| 85 | ||
| 86 | it("returns [] for empty query", async () => { | |
| 87 | const out = await searchPersonalSemantic({ | |
| 88 | userId: "00000000-0000-0000-0000-000000000000", | |
| 89 | query: "", | |
| 90 | }); | |
| 91 | expect(out).toEqual([]); | |
| 92 | }); | |
| 93 | ||
| 94 | it("alias searchAcrossAllReposForUser is identical contract", async () => { | |
| 95 | const out = await searchAcrossAllReposForUser({ | |
| 96 | userId: "", | |
| 97 | query: "foo", | |
| 98 | }); | |
| 99 | expect(out).toEqual([]); | |
| 100 | }); | |
| 101 | ||
| 102 | it("isPersonalSemanticEnabled returns false for empty id", async () => { | |
| 103 | expect(await isPersonalSemanticEnabled("")).toBe(false); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | // --------------------------------------------------------------------------- | |
| 108 | // 2. DB-backed pipeline. | |
| 109 | // --------------------------------------------------------------------------- | |
| 110 | ||
| 111 | describe.skipIf(!HAS_DB)("personal-semantic — DB-backed", () => { | |
| 112 | it.skipIf(!HAS_DB)( | |
| 113 | "refuses to return rows when the opt-in flag is OFF", | |
| 114 | async () => { | |
| 115 | const { db } = await import("../db"); | |
| 116 | const { users, repositories, codeEmbeddings } = await import( | |
| 117 | "../db/schema" | |
| 118 | ); | |
| 119 | const { eq } = await import("drizzle-orm"); | |
| 120 | ||
| 121 | const stamp = randomBytes(4).toString("hex"); | |
| 122 | const username = `psem-off-${stamp}`; | |
| 123 | const reponame = `psem-off-${stamp}`; | |
| 124 | ||
| 125 | const [u] = await db | |
| 126 | .insert(users) | |
| 127 | .values({ | |
| 128 | username, | |
| 129 | email: `${username}@test.local`, | |
| 130 | passwordHash: "x", | |
| 131 | personalSemanticIndexEnabled: false, | |
| 132 | }) | |
| 133 | .returning(); | |
| 134 | if (!u) return; | |
| 135 | ||
| 136 | await initBareRepo(username, reponame); | |
| 137 | const [r] = await db | |
| 138 | .insert(repositories) | |
| 139 | .values({ | |
| 140 | name: reponame, | |
| 141 | ownerId: u.id, | |
| 142 | diskPath: getRepoPath(username, reponame), | |
| 143 | defaultBranch: "main", | |
| 144 | }) | |
| 145 | .returning(); | |
| 146 | if (!r) return; | |
| 147 | ||
| 148 | // Plant an embedding row; with opt-in OFF the search must not | |
| 149 | // surface it even though the user owns the repo. | |
| 150 | const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0); | |
| 151 | fakeVec[0] = 1; | |
| 152 | try { | |
| 153 | await db.insert(codeEmbeddings).values({ | |
| 154 | repositoryId: r.id, | |
| 155 | filePath: "src/secret.ts", | |
| 156 | blobSha: "aa11", | |
| 157 | commitSha: "aa11", | |
| 158 | contentSnippet: "// SECRET KEY", | |
| 159 | embedding: fakeVec, | |
| 160 | embeddingModel: "stub", | |
| 161 | }); | |
| 162 | } catch { | |
| 163 | /* pgvector may not exist; the opt-in refusal is still meaningful | |
| 164 | because it short-circuits BEFORE the cosine ORDER BY runs. */ | |
| 165 | } | |
| 166 | ||
| 167 | __setEmbedderForTests(async () => ({ | |
| 168 | vector: fakeVec, | |
| 169 | model: "stub-1024", | |
| 170 | })); | |
| 171 | ||
| 172 | const hits = await searchPersonalSemantic({ | |
| 173 | userId: u.id, | |
| 174 | query: "secret", | |
| 175 | }); | |
| 176 | expect(hits).toEqual([]); | |
| 177 | ||
| 178 | // Now flip on and verify the hit does surface — proves the refusal | |
| 179 | // above is the flag, not a side-effect of missing data. | |
| 180 | await setPersonalSemanticEnabled(u.id, true); | |
| 181 | const after = await searchPersonalSemantic({ | |
| 182 | userId: u.id, | |
| 183 | query: "secret", | |
| 184 | }); | |
| 185 | // pgvector may not be installed; allow either an empty result or | |
| 186 | // the planted row. The point of THIS assertion is that we no | |
| 187 | // longer SHORT-CIRCUIT — DB-level behaviour is allowed to be empty. | |
| 188 | expect(Array.isArray(after)).toBe(true); | |
| 189 | if (after.length) { | |
| 190 | expect(after[0].repoName).toBe(`${username}/${reponame}`); | |
| 191 | expect(after[0].filePath).toBe("src/secret.ts"); | |
| 192 | } | |
| 193 | ||
| 194 | // Cleanup. | |
| 195 | try { | |
| 196 | await db | |
| 197 | .delete(codeEmbeddings) | |
| 198 | .where(eq(codeEmbeddings.repositoryId, r.id)); | |
| 199 | } catch { | |
| 200 | /* may not exist */ | |
| 201 | } | |
| 202 | await db.delete(repositories).where(eq(repositories.id, r.id)); | |
| 203 | await db.delete(users).where(eq(users.id, u.id)); | |
| 204 | } | |
| 205 | ); | |
| 206 | ||
| 207 | it.skipIf(!HAS_DB)( | |
| 208 | "search returns results across multiple owned repos", | |
| 209 | async () => { | |
| 210 | const { db } = await import("../db"); | |
| 211 | const { users, repositories, codeEmbeddings } = await import( | |
| 212 | "../db/schema" | |
| 213 | ); | |
| 214 | const { eq, inArray } = await import("drizzle-orm"); | |
| 215 | ||
| 216 | const stamp = randomBytes(4).toString("hex"); | |
| 217 | const username = `psem-multi-${stamp}`; | |
| 218 | ||
| 219 | const [u] = await db | |
| 220 | .insert(users) | |
| 221 | .values({ | |
| 222 | username, | |
| 223 | email: `${username}@test.local`, | |
| 224 | passwordHash: "x", | |
| 225 | personalSemanticIndexEnabled: true, | |
| 226 | }) | |
| 227 | .returning(); | |
| 228 | if (!u) return; | |
| 229 | ||
| 230 | const repoA = `psem-multi-a-${stamp}`; | |
| 231 | const repoB = `psem-multi-b-${stamp}`; | |
| 232 | await initBareRepo(username, repoA); | |
| 233 | await initBareRepo(username, repoB); | |
| 234 | const [rA] = await db | |
| 235 | .insert(repositories) | |
| 236 | .values({ | |
| 237 | name: repoA, | |
| 238 | ownerId: u.id, | |
| 239 | diskPath: getRepoPath(username, repoA), | |
| 240 | defaultBranch: "main", | |
| 241 | }) | |
| 242 | .returning(); | |
| 243 | const [rB] = await db | |
| 244 | .insert(repositories) | |
| 245 | .values({ | |
| 246 | name: repoB, | |
| 247 | ownerId: u.id, | |
| 248 | diskPath: getRepoPath(username, repoB), | |
| 249 | defaultBranch: "main", | |
| 250 | }) | |
| 251 | .returning(); | |
| 252 | if (!rA || !rB) return; | |
| 253 | ||
| 254 | const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0); | |
| 255 | fakeVec[0] = 1; | |
| 256 | let inserted = false; | |
| 257 | try { | |
| 258 | await db.insert(codeEmbeddings).values([ | |
| 259 | { | |
| 260 | repositoryId: rA.id, | |
| 261 | filePath: "src/a.ts", | |
| 262 | blobSha: "aaa1", | |
| 263 | commitSha: "aaa1", | |
| 264 | contentSnippet: "// repo a snippet", | |
| 265 | embedding: fakeVec, | |
| 266 | embeddingModel: "stub", | |
| 267 | }, | |
| 268 | { | |
| 269 | repositoryId: rB.id, | |
| 270 | filePath: "src/b.ts", | |
| 271 | blobSha: "bbb1", | |
| 272 | commitSha: "bbb1", | |
| 273 | contentSnippet: "// repo b snippet", | |
| 274 | embedding: fakeVec, | |
| 275 | embeddingModel: "stub", | |
| 276 | }, | |
| 277 | ]); | |
| 278 | inserted = true; | |
| 279 | } catch { | |
| 280 | /* pgvector missing — we'll skip the surface assertion below */ | |
| 281 | } | |
| 282 | ||
| 283 | __setEmbedderForTests(async () => ({ | |
| 284 | vector: fakeVec, | |
| 285 | model: "stub-1024", | |
| 286 | })); | |
| 287 | ||
| 288 | const hits = await searchPersonalSemantic({ | |
| 289 | userId: u.id, | |
| 290 | query: "snippet", | |
| 291 | }); | |
| 292 | expect(Array.isArray(hits)).toBe(true); | |
| 293 | ||
| 294 | // When pgvector is available, both repos should show up — they're | |
| 295 | // both owned by the user, so the union contains both. | |
| 296 | if (inserted && hits.length) { | |
| 297 | const repoNames = new Set(hits.map((h) => h.repoName)); | |
| 298 | expect(repoNames.size).toBeGreaterThanOrEqual(1); | |
| 299 | for (const h of hits) { | |
| 300 | expect(typeof h.repoName).toBe("string"); | |
| 301 | expect(h.repoName.startsWith(`${username}/`)).toBe(true); | |
| 302 | } | |
| 303 | } | |
| 304 | ||
| 305 | // Cleanup. | |
| 306 | try { | |
| 307 | await db | |
| 308 | .delete(codeEmbeddings) | |
| 309 | .where(inArray(codeEmbeddings.repositoryId, [rA.id, rB.id])); | |
| 310 | } catch { | |
| 311 | /* may not exist */ | |
| 312 | } | |
| 313 | await db | |
| 314 | .delete(repositories) | |
| 315 | .where(inArray(repositories.id, [rA.id, rB.id])); | |
| 316 | await db.delete(users).where(eq(users.id, u.id)); | |
| 317 | } | |
| 318 | ); | |
| 319 | ||
| 320 | it.skipIf(!HAS_DB)( | |
| 321 | "search EXCLUDES repos the user has no access to", | |
| 322 | async () => { | |
| 323 | const { db } = await import("../db"); | |
| 324 | const { users, repositories, codeEmbeddings } = await import( | |
| 325 | "../db/schema" | |
| 326 | ); | |
| 327 | const { eq, inArray } = await import("drizzle-orm"); | |
| 328 | ||
| 329 | const stamp = randomBytes(4).toString("hex"); | |
| 330 | const userA = `psem-exclA-${stamp}`; | |
| 331 | const userB = `psem-exclB-${stamp}`; | |
| 332 | ||
| 333 | const [uA] = await db | |
| 334 | .insert(users) | |
| 335 | .values({ | |
| 336 | username: userA, | |
| 337 | email: `${userA}@test.local`, | |
| 338 | passwordHash: "x", | |
| 339 | personalSemanticIndexEnabled: true, | |
| 340 | }) | |
| 341 | .returning(); | |
| 342 | const [uB] = await db | |
| 343 | .insert(users) | |
| 344 | .values({ | |
| 345 | username: userB, | |
| 346 | email: `${userB}@test.local`, | |
| 347 | passwordHash: "x", | |
| 348 | }) | |
| 349 | .returning(); | |
| 350 | if (!uA || !uB) return; | |
| 351 | ||
| 352 | // User B owns a private repo. User A has NO collaborator row for it. | |
| 353 | const repoB = `psem-excl-${stamp}`; | |
| 354 | await initBareRepo(userB, repoB); | |
| 355 | const [rB] = await db | |
| 356 | .insert(repositories) | |
| 357 | .values({ | |
| 358 | name: repoB, | |
| 359 | ownerId: uB.id, | |
| 360 | diskPath: getRepoPath(userB, repoB), | |
| 361 | defaultBranch: "main", | |
| 362 | isPrivate: true, | |
| 363 | }) | |
| 364 | .returning(); | |
| 365 | if (!rB) return; | |
| 366 | ||
| 367 | const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0); | |
| 368 | fakeVec[0] = 1; | |
| 369 | try { | |
| 370 | await db.insert(codeEmbeddings).values({ | |
| 371 | repositoryId: rB.id, | |
| 372 | filePath: "src/foreign.ts", | |
| 373 | blobSha: "cccc", | |
| 374 | commitSha: "cccc", | |
| 375 | contentSnippet: "// USER B's PRIVATE CODE", | |
| 376 | embedding: fakeVec, | |
| 377 | embeddingModel: "stub", | |
| 378 | }); | |
| 379 | } catch { | |
| 380 | /* pgvector missing — refusal must still hold via the IN ([]) path */ | |
| 381 | } | |
| 382 | ||
| 383 | __setEmbedderForTests(async () => ({ | |
| 384 | vector: fakeVec, | |
| 385 | model: "stub-1024", | |
| 386 | })); | |
| 387 | ||
| 388 | // User A has no repos of their own and no collaborator rows. | |
| 389 | // The accessible-repo-set must be empty, so search returns [] without | |
| 390 | // EVER consulting User B's embedding row. | |
| 391 | const hits = await searchPersonalSemantic({ | |
| 392 | userId: uA.id, | |
| 393 | query: "private", | |
| 394 | }); | |
| 395 | expect(hits).toEqual([]); | |
| 396 | ||
| 397 | // Even if User A asks for the exact snippet text, the WHERE clause | |
| 398 | // gates them out. | |
| 399 | const hits2 = await searchPersonalSemantic({ | |
| 400 | userId: uA.id, | |
| 401 | query: "USER B's PRIVATE CODE", | |
| 402 | }); | |
| 403 | expect(hits2).toEqual([]); | |
| 404 | ||
| 405 | // Cleanup. | |
| 406 | try { | |
| 407 | await db | |
| 408 | .delete(codeEmbeddings) | |
| 409 | .where(inArray(codeEmbeddings.repositoryId, [rB.id])); | |
| 410 | } catch { | |
| 411 | /* may not exist */ | |
| 412 | } | |
| 413 | await db.delete(repositories).where(eq(repositories.id, rB.id)); | |
| 414 | await db.delete(users).where(eq(users.id, uA.id)); | |
| 415 | await db.delete(users).where(eq(users.id, uB.id)); | |
| 416 | } | |
| 417 | ); | |
| 418 | ||
| 419 | it.skipIf(!HAS_DB)( | |
| 420 | "cross-user content leak prevention — User A's results never contain User B's repo data", | |
| 421 | async () => { | |
| 422 | // This test is the load-bearing security assertion. We give User A | |
| 423 | // one owned repo with data, and User B one separate owned repo with | |
| 424 | // distinctively different data. Both have the opt-in flag on. A's | |
| 425 | // search must only see A's repo; B's results must not appear in A's. | |
| 426 | const { db } = await import("../db"); | |
| 427 | const { users, repositories, codeEmbeddings } = await import( | |
| 428 | "../db/schema" | |
| 429 | ); | |
| 430 | const { eq, inArray } = await import("drizzle-orm"); | |
| 431 | ||
| 432 | const stamp = randomBytes(4).toString("hex"); | |
| 433 | const userA = `psem-leakA-${stamp}`; | |
| 434 | const userB = `psem-leakB-${stamp}`; | |
| 435 | ||
| 436 | const [uA] = await db | |
| 437 | .insert(users) | |
| 438 | .values({ | |
| 439 | username: userA, | |
| 440 | email: `${userA}@test.local`, | |
| 441 | passwordHash: "x", | |
| 442 | personalSemanticIndexEnabled: true, | |
| 443 | }) | |
| 444 | .returning(); | |
| 445 | const [uB] = await db | |
| 446 | .insert(users) | |
| 447 | .values({ | |
| 448 | username: userB, | |
| 449 | email: `${userB}@test.local`, | |
| 450 | passwordHash: "x", | |
| 451 | personalSemanticIndexEnabled: true, | |
| 452 | }) | |
| 453 | .returning(); | |
| 454 | if (!uA || !uB) return; | |
| 455 | ||
| 456 | const repoA = `psem-leak-a-${stamp}`; | |
| 457 | const repoB = `psem-leak-b-${stamp}`; | |
| 458 | await initBareRepo(userA, repoA); | |
| 459 | await initBareRepo(userB, repoB); | |
| 460 | const [rA] = await db | |
| 461 | .insert(repositories) | |
| 462 | .values({ | |
| 463 | name: repoA, | |
| 464 | ownerId: uA.id, | |
| 465 | diskPath: getRepoPath(userA, repoA), | |
| 466 | defaultBranch: "main", | |
| 467 | }) | |
| 468 | .returning(); | |
| 469 | const [rB] = await db | |
| 470 | .insert(repositories) | |
| 471 | .values({ | |
| 472 | name: repoB, | |
| 473 | ownerId: uB.id, | |
| 474 | diskPath: getRepoPath(userB, repoB), | |
| 475 | defaultBranch: "main", | |
| 476 | }) | |
| 477 | .returning(); | |
| 478 | if (!rA || !rB) return; | |
| 479 | ||
| 480 | const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0); | |
| 481 | fakeVec[0] = 1; | |
| 482 | try { | |
| 483 | await db.insert(codeEmbeddings).values([ | |
| 484 | { | |
| 485 | repositoryId: rA.id, | |
| 486 | filePath: "src/owned-by-a.ts", | |
| 487 | blobSha: "aaaa", | |
| 488 | commitSha: "aaaa", | |
| 489 | contentSnippet: "// A's code", | |
| 490 | embedding: fakeVec, | |
| 491 | embeddingModel: "stub", | |
| 492 | }, | |
| 493 | { | |
| 494 | repositoryId: rB.id, | |
| 495 | filePath: "src/owned-by-b.ts", | |
| 496 | blobSha: "bbbb", | |
| 497 | commitSha: "bbbb", | |
| 498 | contentSnippet: "// B's code", | |
| 499 | embedding: fakeVec, | |
| 500 | embeddingModel: "stub", | |
| 501 | }, | |
| 502 | ]); | |
| 503 | } catch { | |
| 504 | /* pgvector missing — the next assertions still hold via the | |
| 505 | empty-repo-set short-circuit / IN clause */ | |
| 506 | } | |
| 507 | ||
| 508 | __setEmbedderForTests(async () => ({ | |
| 509 | vector: fakeVec, | |
| 510 | model: "stub-1024", | |
| 511 | })); | |
| 512 | ||
| 513 | const aHits = await searchPersonalSemantic({ | |
| 514 | userId: uA.id, | |
| 515 | query: "code", | |
| 516 | }); | |
| 517 | // Every hit must be from a repo A owns. The file owned-by-b.ts | |
| 518 | // must never appear; the repo name must never include userB. | |
| 519 | for (const h of aHits) { | |
| 520 | expect(h.ownerName).toBe(userA); | |
| 521 | expect(h.repoName).toBe(`${userA}/${repoA}`); | |
| 522 | expect(h.filePath).not.toBe("src/owned-by-b.ts"); | |
| 523 | } | |
| 524 | ||
| 525 | const bHits = await searchPersonalSemantic({ | |
| 526 | userId: uB.id, | |
| 527 | query: "code", | |
| 528 | }); | |
| 529 | for (const h of bHits) { | |
| 530 | expect(h.ownerName).toBe(userB); | |
| 531 | expect(h.repoName).toBe(`${userB}/${repoB}`); | |
| 532 | expect(h.filePath).not.toBe("src/owned-by-a.ts"); | |
| 533 | } | |
| 534 | ||
| 535 | // Cleanup. | |
| 536 | try { | |
| 537 | await db | |
| 538 | .delete(codeEmbeddings) | |
| 539 | .where(inArray(codeEmbeddings.repositoryId, [rA.id, rB.id])); | |
| 540 | } catch { | |
| 541 | /* may not exist */ | |
| 542 | } | |
| 543 | await db | |
| 544 | .delete(repositories) | |
| 545 | .where(inArray(repositories.id, [rA.id, rB.id])); | |
| 546 | await db.delete(users).where(eq(users.id, uA.id)); | |
| 547 | await db.delete(users).where(eq(users.id, uB.id)); | |
| 548 | } | |
| 549 | ); | |
| 550 | ||
| 551 | it.skipIf(!HAS_DB)( | |
| 552 | "setPersonalSemanticEnabled flips the flag and isPersonalSemanticEnabled reads it", | |
| 553 | async () => { | |
| 554 | const { db } = await import("../db"); | |
| 555 | const { users } = await import("../db/schema"); | |
| 556 | const { eq } = await import("drizzle-orm"); | |
| 557 | ||
| 558 | const stamp = randomBytes(4).toString("hex"); | |
| 559 | const username = `psem-flag-${stamp}`; | |
| 560 | const [u] = await db | |
| 561 | .insert(users) | |
| 562 | .values({ | |
| 563 | username, | |
| 564 | email: `${username}@test.local`, | |
| 565 | passwordHash: "x", | |
| 566 | }) | |
| 567 | .returning(); | |
| 568 | if (!u) return; | |
| 569 | ||
| 570 | expect(await isPersonalSemanticEnabled(u.id)).toBe(false); | |
| 571 | const r1 = await setPersonalSemanticEnabled(u.id, true); | |
| 572 | expect(r1).toBe(true); | |
| 573 | expect(await isPersonalSemanticEnabled(u.id)).toBe(true); | |
| 574 | ||
| 575 | const r2 = await setPersonalSemanticEnabled(u.id, false); | |
| 576 | expect(r2).toBe(false); | |
| 577 | expect(await isPersonalSemanticEnabled(u.id)).toBe(false); | |
| 578 | ||
| 579 | await db.delete(users).where(eq(users.id, u.id)); | |
| 580 | } | |
| 581 | ); | |
| 582 | }); |