From 3ce3b5388f21b6a57fddca793b964e1c7198a27e Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Wed, 10 Jun 2026 01:42:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test(database):=20raise=20model/rep?= =?UTF-8?q?ository=20coverage=20to=2095%+=20and=20document=20DB=20test=20c?= =?UTF-8?q?onventions=20(#15611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✅ test(database): raise model/repository coverage to 95%+ and document DB test conventions Raise @lobechat/database client-db coverage 89.11% -> 95.36%: - New integration tests for connector, connectorTool, workspaceMember (were 0%) - Extend task, workspace, rbac, notification, userMemory/query, file, agentSignal/reviewContext, verifyRubric, brief, taskTopic, dataImporter, messengerAccountLink, home Fix client-db (PGlite) test failures: BM25 search lacks the pg_search extension under PGlite, so wrap session.queryByKeyword and home.searchAgents in describe.skipIf(!isServerDB), matching the existing convention. Document DB model/repository testing conventions so new models ship with tests: - Rewrite testing skill's db-model-test.md (getTestDB integration pattern, client-vs-server-db split, BM25 skipIf guard, schema gotchas, user isolation) - Surface the rule in testing/SKILL.md, cross-link from drizzle/SKILL.md, review-checklist/SKILL.md, and models/_template.ts Co-Authored-By: Claude Opus 4.8 * ✅ test(database): make verifyRubric/brief ordering tests deterministic These models order by `updatedAt`/`createdAt` desc with no id tiebreaker, and the tests created rows back-to-back relying on default `now()` — when two rows land in the same millisecond the order is non-deterministic, causing flaky CI failures. Set explicit, well-separated timestamps instead. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .agents/skills/drizzle/SKILL.md | 8 + .agents/skills/review-checklist/SKILL.md | 1 + .agents/skills/testing/SKILL.md | 10 +- .../testing/references/db-model-test.md | 222 ++-- .../src/models/__tests__/brief.test.ts | 266 +++- .../src/models/__tests__/connector.test.ts | 372 ++++++ .../models/__tests__/connectorTool.test.ts | 359 +++++ .../src/models/__tests__/file.test.ts | 205 +++ .../__tests__/messengerAccountLink.test.ts | 187 +++ .../src/models/__tests__/notification.test.ts | 324 ++++- .../src/models/__tests__/rbac.test.ts | 231 ++++ .../src/models/__tests__/session.test.ts | 4 +- .../src/models/__tests__/task.test.ts | 543 +++++++- .../src/models/__tests__/taskTopic.test.ts | 151 ++- .../src/models/__tests__/verifyRubric.test.ts | 166 ++- .../src/models/__tests__/workspace.test.ts | 238 ++++ .../models/__tests__/workspaceMember.test.ts | 330 +++++ packages/database/src/models/_template.ts | 4 + .../__tests__/reviewContext.test.ts | 225 ++++ .../__tests__/query.extended.test.ts | 1149 +++++++++++++++++ .../dataImporter/__tests__/index.test.ts | 230 +++- .../repositories/home/__tests__/index.test.ts | 226 ++++ .../src/repositories/home/index.test.ts | 4 +- 23 files changed, 5351 insertions(+), 104 deletions(-) create mode 100644 packages/database/src/models/__tests__/connector.test.ts create mode 100644 packages/database/src/models/__tests__/connectorTool.test.ts create mode 100644 packages/database/src/models/__tests__/workspaceMember.test.ts create mode 100644 packages/database/src/models/userMemory/__tests__/query.extended.test.ts diff --git a/.agents/skills/drizzle/SKILL.md b/.agents/skills/drizzle/SKILL.md index 81d7229150..7bbad9734a 100644 --- a/.agents/skills/drizzle/SKILL.md +++ b/.agents/skills/drizzle/SKILL.md @@ -6,6 +6,14 @@ user-invocable: false # Drizzle ORM Schema Style Guide +> **Adding a Model or Repository?** Ship a sibling test in the same PR — every new +> file under `packages/database/src/models/**` or `src/repositories/**` needs a +> matching `__tests__/.test.ts`. See the **testing** skill +> (`.agents/skills/testing/references/db-model-test.md`) for the `getTestDB()` +> integration pattern, user-isolation tests, the BM25 `describe.skipIf(!isServerDB)` +> guard, and schema gotchas. CI's coverage patch gate won't reliably catch a brand-new +> untested file, so this is on you. + ## Configuration - Config: `drizzle.config.ts` diff --git a/.agents/skills/review-checklist/SKILL.md b/.agents/skills/review-checklist/SKILL.md index 61e7b99303..332785eade 100644 --- a/.agents/skills/review-checklist/SKILL.md +++ b/.agents/skills/review-checklist/SKILL.md @@ -22,6 +22,7 @@ user-invocable: false - Bug fixes must include tests covering the fixed scenario - New logic (services, store actions, utilities) should have test coverage +- **New database Model/Repository** (`packages/database/src/models/**`, `src/repositories/**`) must ship a sibling `__tests__/.test.ts` — incl. user-isolation tests; BM25 search guarded by `describe.skipIf(!isServerDB)` (see `/testing` → `db-model-test.md`) - Existing tests still cover the changed behavior? - Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill) diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md index 6f50c73f65..db52c39ce8 100644 --- a/.agents/skills/testing/SKILL.md +++ b/.agents/skills/testing/SKILL.md @@ -14,15 +14,21 @@ user-invocable: false # Run specific test file bunx vitest run --silent='passed-only' '[file-path]' -# Database package (client) +# Database package (client-db, PGlite — default, skips BM25/pg_search) cd packages/database && bunx vitest run --silent='passed-only' '[file]' -# Database package (server) +# Database package (server-db, Postgres — BM25/pgvector parity, what CI measures coverage in) cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]' ``` **Never run** `bun run test` - it runs all 3000+ tests (\~10 minutes). +> **Database models/repositories:** every new file under `packages/database/src/models/**` +> or `src/repositories/**` ships with a sibling `__tests__/.test.ts` in the same PR. +> Use the real DB via `getTestDB()` (integration style), guard BM25/full-text-search blocks +> with `describe.skipIf(!isServerDB)`, and always test user-isolation. See +> `references/db-model-test.md` for setup, schema gotchas, and the client-vs-server-db split. + ## Test Categories | Category | Location | Config | diff --git a/.agents/skills/testing/references/db-model-test.md b/.agents/skills/testing/references/db-model-test.md index 1d0342cf41..aa56a5380c 100644 --- a/.agents/skills/testing/references/db-model-test.md +++ b/.agents/skills/testing/references/db-model-test.md @@ -1,95 +1,74 @@ # Database Model Testing Guide -Test `packages/database` Model layer. +Test the `packages/database` Model and Repository layers. -## Dual Environment Verification (Required) +> **Rule: every new Model or Repository ships with a sibling test in the same PR.** +> A new file under `src/models/**` or `src/repositories/**` must have a matching +> `__tests__/.test.ts`. Coverage runs in server-db mode in CI and the patch +> gate will not always catch a brand-new untested file (a small new file barely +> moves the project total) — so this is a convention, not something CI guarantees. +> Start from the template: `packages/database/src/models/__tests__/_test_template.ts`. + +## Two test environments: client-db vs server-db + +`getTestDB()` (`src/core/getTestDB.ts`) returns different engines based on the +`TEST_SERVER_DB` env var: + +| Mode | Engine | When | Notes | +| ----------------------- | ----------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **client-db** (default) | PGlite (in-memory) | `bunx vitest run` | Migration runner **skips any SQL containing `pg_search` / `bm25`** — the ParadeDB BM25 `@@@` operator does not exist here. | +| **server-db** | node-postgres → `DATABASE_TEST_URL` | `TEST_SERVER_DB=1` | CI uses the `paradedb/paradedb` image (has `pg_search`). **Coverage is measured in this mode** (`test:coverage` → `vitest.config.server.mts`, uploaded to Codecov). | ```bash -# 1. Client environment (fast) -cd packages/database && TEST_SERVER_DB=0 bunx vitest run --silent='passed-only' '[file]' +# 1. Client environment (fast, default — what most local runs use) +cd packages/database && bunx vitest run --silent='passed-only' '[file]' -# 2. Server environment (compatibility) +# 2. Server environment (BM25 / pg_search / pgvector parity, needs DATABASE_TEST_URL) cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]' ``` -## User Permission Check - Security First 🔒 +Implication: client-db coverage **under-counts** any code that needs BM25 (e.g. +`repositories/search/index.ts` reads near-0% locally but is fully covered in CI). +Don't chase those lines locally — confirm via CI/Codecov. -**Critical security requirement**: All user data operations must include permission checks. +## BM25 / full-text search → `describe.skipIf(!isServerDB)` + +Any method using the BM25 `@@@` operator or `sanitizeBm25` (keyword search: +`queryByKeyword`, `searchAgents`, userMemory lexical search, …) **throws under +PGlite** (often swallowed by a `catch` that returns `[]`, so the test silently +fails with empty results). Guard those blocks so they only run in server-db: ```typescript -// ❌ DANGEROUS: Missing permission check -update = async (id: string, data: Partial) => { - return this.db - .update(myTable) - .set(data) - .where(eq(myTable.id, id)) // Only checks ID - .returning(); -}; - -// ✅ SECURE: Permission check included -update = async (id: string, data: Partial) => { - return this.db - .update(myTable) - .set(data) - .where( - and( - eq(myTable.id, id), - eq(myTable.userId, this.userId), // ✅ Permission check - ), - ) - .returning(); -}; -``` - -## Test File Structure - -```typescript -// @vitest-environment node -describe('MyModel', () => { - describe('create', () => { - /* ... */ - }); - describe('queryAll', () => { - /* ... */ - }); - describe('update', () => { - it('should update own records'); - it('should NOT update other users records'); // 🔒 Security - }); - describe('delete', () => { - it('should delete own records'); - it('should NOT delete other users records'); // 🔒 Security - }); - describe('user isolation', () => { - it('should enforce user data isolation'); // 🔒 Core security - }); +// BM25 search requires the pg_search extension (ParadeDB), not available in PGlite +const isServerDB = process.env.TEST_SERVER_DB === '1'; +describe.skipIf(!isServerDB)('queryByKeyword', () => { + /* ... */ }); ``` -## Security Test Example +Convention already used in `session.test.ts`, `topic.query.test.ts`, +`message.query.test.ts`, `home/index.test.ts`, `repositories/search/index.test.ts`. + +## Setup boilerplate + +Top-of-file pattern (see `_test_template.ts` for the full version). Use real DB +integration via `getTestDB()` — **not a mocked `vi.fn()` db**; the integration +style exercises real SQL and gives far deeper coverage. ```typescript -it('should not update records of other users', async () => { - const [otherUserRecord] = await serverDB - .insert(myTable) - .values({ userId: 'other-user', data: 'original' }) - .returning(); +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - const result = await myModel.update(otherUserRecord.id, { data: 'hacked' }); +import { getTestDB } from '../../core/getTestDB'; +import { users } from '../../schemas'; +import type { LobeChatDatabase } from '../../type'; +import { MyModel } from '../myModel'; - expect(result).toBeUndefined(); - const unchanged = await serverDB.query.myTable.findFirst({ - where: eq(myTable.id, otherUserRecord.id), - }); - expect(unchanged?.data).toBe('original'); -}); -``` +const serverDB: LobeChatDatabase = await getTestDB(); // top-level await is fine -## Data Management - -```typescript -const userId = 'test-user'; +const userId = 'my-model-test-user'; const otherUserId = 'other-user'; +const myModel = new MyModel(serverDB, userId); beforeEach(async () => { await serverDB.delete(users); @@ -97,40 +76,99 @@ beforeEach(async () => { }); afterEach(async () => { - await serverDB.delete(users); + await serverDB.delete(users); // cascades to user-scoped rows }); ``` -## Foreign Key Handling +Some tests need the Node environment (pgvector, server-only deps) — add +`// @vitest-environment node` as the first line when required. + +## User permission check — security first 🔒 + +**Every user-data operation must be ownership-scoped.** Always add a test proving +another user cannot read/update/delete the row. ```typescript -// ❌ Wrong: Invalid foreign key +// ✅ SECURE: ownership in the WHERE clause +update = async (id: string, data: Partial) => + this.db + .update(myTable) + .set(data) + .where(and(eq(myTable.id, id), eq(myTable.userId, this.userId))) + .returning(); +``` + +```typescript +it('should NOT update another user's record', async () => { + const otherModel = new MyModel(serverDB, otherUserId); + const [row] = await otherModel.create({ data: 'original' }); + + await myModel.update(row.id, { data: 'hacked' }); + + const unchanged = await serverDB.query.myTable.findFirst({ + where: eq(myTable.id, row.id), + }); + expect(unchanged?.data).toBe('original'); +}); +``` + +## What to cover + +Aim each model/repository as close to 100% as practical (excluding BM25): + +- Every public method +- Both branches of conditionals; empty-list / `if (!x) return []` early returns +- Error fallbacks (e.g. decrypt/JSON-parse failure → `null`) +- Filters, pagination, ordering branches +- Ownership / user isolation, and workspace scoping if the model takes a `workspaceId` + +## Schema gotchas (real traps that fail inserts or types) + +- **`workspaces`** requires `{ id, name, slug, primaryOwnerId }` and has **no + `userId` column** — `insert(workspaces).values({ id, name, slug, primaryOwnerId })`. +- **uuid columns**: a "not found" test must pass a _valid_ UUID + (`'00000000-0000-0000-0000-000000000000'`); a random string raises a `22P02` + DB error instead of returning `undefined`/`null`. +- **Enum / `$type` columns** are type-checked: e.g. `files.source` is a + `FileSource` enum (`image_generation` | `page-editor` | `video_generation`), + not free text — passing `'upload'` is a type error. +- Read the table's schema in `src/schemas/` for `notNull` columns **without + defaults**; you must supply those on insert. + +## Foreign key handling + +```typescript +// ❌ Wrong: invalid foreign key const testData = { asyncTaskId: 'invalid-uuid', fileId: 'non-existent' }; -// ✅ Correct: Use null +// ✅ Use null … const testData = { asyncTaskId: null, fileId: null }; -// ✅ Or: Create referenced record first -beforeEach(async () => { - const [asyncTask] = await serverDB - .insert(asyncTasks) - .values({ id: 'valid-id', status: 'pending' }) - .returning(); - testData.asyncTaskId = asyncTask.id; -}); +// ✅ … or create the referenced row first +const [asyncTask] = await serverDB.insert(asyncTasks).values({ status: 'pending' }).returning(); +testData.asyncTaskId = asyncTask.id; ``` -## Predictable Sorting +## Predictable sorting ```typescript -// ✅ Use explicit timestamps -const oldDate = new Date('2024-01-01T10:00:00Z'); -const newDate = new Date('2024-01-02T10:00:00Z'); +// ✅ Use explicit timestamps — never rely on insert order await serverDB.insert(table).values([ - { ...data1, createdAt: oldDate }, - { ...data2, createdAt: newDate }, + { ...data1, createdAt: new Date('2024-01-01T10:00:00Z') }, + { ...data2, createdAt: new Date('2024-01-02T10:00:00Z') }, ]); - -// ❌ Don't rely on insert order -await serverDB.insert(table).values([data1, data2]); // Unpredictable ``` + +## Checking coverage of one file + +```bash +# Per-file coverage; read the "Uncovered Line #s" column to find gaps +cd packages/database +bunx vitest run --coverage --silent='passed-only' '[test-file]' 2>&1 | grep '[sourceFile].ts' +``` + +## Before finishing + +1. Tests pass: `bunx vitest run --silent='passed-only' '[file]'` +2. Types pass: `bun run type-check` (vitest uses esbuild and does **not** + type-check — a green test run can still have type errors). diff --git a/packages/database/src/models/__tests__/brief.test.ts b/packages/database/src/models/__tests__/brief.test.ts index 9b07992ab0..19d2badd99 100644 --- a/packages/database/src/models/__tests__/brief.test.ts +++ b/packages/database/src/models/__tests__/brief.test.ts @@ -1,8 +1,9 @@ // @vitest-environment node +import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getTestDB } from '../../core/getTestDB'; -import { agents, tasks, users } from '../../schemas'; +import { agentCronJobs, agents, briefs, tasks, users } from '../../schemas'; import type { LobeChatDatabase } from '../../type'; import { BriefModel } from '../brief'; @@ -267,6 +268,269 @@ describe('BriefModel', () => { expect(rows).toHaveLength(1); expect(rows[0].summary).toBe('Matching proposal'); }); + + it('should exclude resolved briefs and respect the default limit', async () => { + const model = new BriefModel(serverDB, userId); + + const resolved = await model.create({ + agentId: 'agent-1', + summary: 'Resolved match', + title: 'Resolved', + trigger: 'agent-signal:nightly-review', + type: 'decision', + }); + await model.resolve(resolved.id); + + await model.create({ + agentId: 'agent-1', + summary: 'Open match', + title: 'Open', + trigger: 'agent-signal:nightly-review', + type: 'decision', + }); + + const rows = await model.listUnresolvedByAgentAndTrigger({ + agentId: 'agent-1', + trigger: 'agent-signal:nightly-review', + }); + + expect(rows).toHaveLength(1); + expect(rows[0].summary).toBe('Open match'); + }); + }); + + describe('findByTaskId', () => { + it('should return briefs for a task ordered newest first', async () => { + await serverDB.insert(tasks).values({ + createdByUserId: userId, + id: 'task-find', + identifier: 'TASK-FIND', + instruction: 'do work', + name: 'Task Find', + seq: 1, + status: 'running', + }); + + const model = new BriefModel(serverDB, userId); + const first = await model.create({ + summary: 'First', + taskId: 'task-find', + title: 'First', + type: 'result', + }); + const second = await model.create({ + summary: 'Second', + taskId: 'task-find', + title: 'Second', + type: 'result', + }); + // unrelated brief without the task + await model.create({ summary: 'Other', title: 'Other', type: 'result' }); + + // Explicit, separated createdAt so `desc(createdAt)` ordering is deterministic + // (the model has no id tiebreaker; back-to-back creates can tie within a ms). + await serverDB + .update(briefs) + .set({ createdAt: new Date('2025-01-01T00:00:00Z') }) + .where(eq(briefs.id, first.id)); + await serverDB + .update(briefs) + .set({ createdAt: new Date('2025-01-02T00:00:00Z') }) + .where(eq(briefs.id, second.id)); + + const rows = await model.findByTaskId('task-find'); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.id)).toEqual([second.id, first.id]); + }); + + it('should not return briefs owned by another user for the same task', async () => { + await serverDB.insert(tasks).values({ + createdByUserId: userId, + id: 'task-shared', + identifier: 'TASK-SHARED', + instruction: 'do work', + name: 'Task Shared', + seq: 2, + status: 'running', + }); + + const model1 = new BriefModel(serverDB, userId); + await model1.create({ + summary: 'Owned by 1', + taskId: 'task-shared', + title: 'Owned', + type: 'result', + }); + + const model2 = new BriefModel(serverDB, userId2); + const rows = await model2.findByTaskId('task-shared'); + expect(rows).toHaveLength(0); + }); + }); + + describe('hasUnresolvedUrgentByTask', () => { + it('should return true when an unresolved urgent brief exists for the task', async () => { + await serverDB.insert(tasks).values({ + createdByUserId: userId, + id: 'task-urgent', + identifier: 'TASK-URGENT', + instruction: 'do work', + name: 'Task Urgent', + seq: 1, + status: 'running', + }); + + const model = new BriefModel(serverDB, userId); + await model.create({ + priority: 'urgent', + summary: 'Needs review', + taskId: 'task-urgent', + title: 'Urgent', + type: 'decision', + }); + + expect(await model.hasUnresolvedUrgentByTask('task-urgent')).toBe(true); + }); + + it('should return false when the only urgent brief is resolved', async () => { + await serverDB.insert(tasks).values({ + createdByUserId: userId, + id: 'task-resolved', + identifier: 'TASK-RESOLVED', + instruction: 'do work', + name: 'Task Resolved', + seq: 2, + status: 'running', + }); + + const model = new BriefModel(serverDB, userId); + const brief = await model.create({ + priority: 'urgent', + summary: 'Resolved urgent', + taskId: 'task-resolved', + title: 'Resolved', + type: 'decision', + }); + await model.resolve(brief.id); + + expect(await model.hasUnresolvedUrgentByTask('task-resolved')).toBe(false); + }); + + it('should return false when the urgent brief type is excluded', async () => { + await serverDB.insert(tasks).values({ + createdByUserId: userId, + id: 'task-excluded', + identifier: 'TASK-EXCLUDED', + instruction: 'do work', + name: 'Task Excluded', + seq: 3, + status: 'running', + }); + + const model = new BriefModel(serverDB, userId); + await model.create({ + priority: 'urgent', + summary: 'Transient error', + taskId: 'task-excluded', + title: 'Error', + type: 'error', + }); + + expect( + await model.hasUnresolvedUrgentByTask('task-excluded', { excludeTypes: ['error'] }), + ).toBe(false); + // a non-excluded urgent brief still flips it back to true + await model.create({ + priority: 'urgent', + summary: 'Decision needed', + taskId: 'task-excluded', + title: 'Decision', + type: 'decision', + }); + expect( + await model.hasUnresolvedUrgentByTask('task-excluded', { excludeTypes: ['error'] }), + ).toBe(true); + }); + + it('should return false when there are no matching briefs', async () => { + const model = new BriefModel(serverDB, userId); + expect(await model.hasUnresolvedUrgentByTask('nonexistent-task')).toBe(false); + }); + }); + + describe('findByCronJobId', () => { + it('should return briefs for a cron job ordered newest first', async () => { + await serverDB.insert(agents).values({ id: 'agent-cron', title: 'Cron Agent', userId }); + await serverDB.insert(agentCronJobs).values({ + agentId: 'agent-cron', + content: 'do it', + cronPattern: '*/30 * * * *', + id: 'cron-1', + userId, + }); + + const model = new BriefModel(serverDB, userId); + const first = await model.create({ + cronJobId: 'cron-1', + summary: 'First', + title: 'First', + type: 'result', + }); + const second = await model.create({ + cronJobId: 'cron-1', + summary: 'Second', + title: 'Second', + type: 'result', + }); + await model.create({ summary: 'No cron', title: 'No cron', type: 'result' }); + + // Explicit, separated createdAt so `desc(createdAt)` ordering is deterministic. + await serverDB + .update(briefs) + .set({ createdAt: new Date('2025-01-01T00:00:00Z') }) + .where(eq(briefs.id, first.id)); + await serverDB + .update(briefs) + .set({ createdAt: new Date('2025-01-02T00:00:00Z') }) + .where(eq(briefs.id, second.id)); + + const rows = await model.findByCronJobId('cron-1'); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.id)).toEqual([second.id, first.id]); + }); + + it('should return empty when no briefs match the cron job', async () => { + const model = new BriefModel(serverDB, userId); + const rows = await model.findByCronJobId('missing-cron'); + expect(rows).toHaveLength(0); + }); + }); + + describe('updateMetadata', () => { + it('should persist metadata without resolving the brief', async () => { + const model = new BriefModel(serverDB, userId); + const brief = await model.create({ summary: 'A', title: 'Test', type: 'decision' }); + + const updated = await model.updateMetadata(brief.id, { proposalState: 'stale' } as any); + expect(updated).not.toBeNull(); + expect(updated!.metadata).toEqual({ proposalState: 'stale' }); + expect(updated!.resolvedAt).toBeNull(); + }); + + it('should return null when the brief does not exist', async () => { + const model = new BriefModel(serverDB, userId); + const updated = await model.updateMetadata('missing-id', { foo: 'bar' } as any); + expect(updated).toBeNull(); + }); + + it('should not update a brief owned by another user', async () => { + const model1 = new BriefModel(serverDB, userId); + const brief = await model1.create({ summary: 'A', title: 'Test', type: 'decision' }); + + const model2 = new BriefModel(serverDB, userId2); + const updated = await model2.updateMetadata(brief.id, { foo: 'bar' } as any); + expect(updated).toBeNull(); + }); }); describe('markRead', () => { diff --git a/packages/database/src/models/__tests__/connector.test.ts b/packages/database/src/models/__tests__/connector.test.ts new file mode 100644 index 0000000000..1699cf1e48 --- /dev/null +++ b/packages/database/src/models/__tests__/connector.test.ts @@ -0,0 +1,372 @@ +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTestDB } from '../../core/getTestDB'; +import type { ConnectorCredentials } from '../../schemas'; +import { userConnectors, users, workspaces } from '../../schemas'; +import type { LobeChatDatabase } from '../../type'; +import { ConnectorModel } from '../connector'; + +const serverDB: LobeChatDatabase = await getTestDB(); + +const userId = 'connector-user'; +const otherUserId = 'connector-other-user'; +const workspaceId = 'connector-workspace'; + +// A gateKeeper that wraps/unwraps payloads with a recognizable prefix so we can +// assert encryption actually ran without depending on real crypto. +const gateKeeper = { + decrypt: vi.fn(async (ciphertext: string) => ({ + plaintext: ciphertext.replace(/^enc:/, ''), + })), + encrypt: vi.fn(async (plaintext: string) => `enc:${plaintext}`), +}; + +const apikeyCredentials: ConnectorCredentials = { apiKey: 'secret-key', type: 'apikey' }; + +beforeEach(async () => { + vi.clearAllMocks(); + await serverDB.delete(users); + await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]); + await serverDB + .insert(workspaces) + .values({ id: workspaceId, name: 'WS', primaryOwnerId: userId, slug: 'ws' }); +}); + +afterEach(async () => { + await serverDB.delete(users); +}); + +describe('ConnectorModel', () => { + describe('create', () => { + it('creates a connector without credentials', async () => { + const model = new ConnectorModel(serverDB, userId); + + const result = await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + expect(result.id).toBeDefined(); + expect(result.userId).toBe(userId); + expect(result.workspaceId).toBeNull(); + expect(result.credentials).toBeNull(); + expect(gateKeeper.encrypt).not.toHaveBeenCalled(); + }); + + it('encrypts credentials with the constructor gateKeeper', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + + const result = await model.create({ + credentials: JSON.stringify(apikeyCredentials), + identifier: 'github', + name: 'GitHub', + sourceType: 'builtin', + status: 'connected', + }); + + expect(gateKeeper.encrypt).toHaveBeenCalledOnce(); + expect(result.credentials).toBe(`enc:${JSON.stringify(apikeyCredentials)}`); + }); + + it('stores plaintext credentials when no gateKeeper is provided', async () => { + const model = new ConnectorModel(serverDB, userId); + + const result = await model.create({ + credentials: JSON.stringify(apikeyCredentials), + identifier: 'custom', + name: 'Custom', + sourceType: 'custom', + status: 'connected', + }); + + expect(result.credentials).toBe(JSON.stringify(apikeyCredentials)); + }); + + it('persists workspaceId when the model is workspace-scoped', async () => { + const model = new ConnectorModel(serverDB, userId, workspaceId); + + const result = await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + expect(result.workspaceId).toBe(workspaceId); + }); + + it('uses the gateKeeper passed to create over the constructor one', async () => { + const model = new ConnectorModel(serverDB, userId); + const callGateKeeper = { + decrypt: vi.fn(), + encrypt: vi.fn(async (plaintext: string) => `call:${plaintext}`), + }; + + const result = await model.create( + { + credentials: JSON.stringify(apikeyCredentials), + identifier: 'github', + name: 'GitHub', + sourceType: 'builtin', + status: 'connected', + }, + callGateKeeper, + ); + + expect(callGateKeeper.encrypt).toHaveBeenCalledOnce(); + expect(result.credentials).toBe(`call:${JSON.stringify(apikeyCredentials)}`); + }); + }); + + describe('query', () => { + it('returns only the current user / workspace connectors with decrypted credentials', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + + await model.create({ + credentials: JSON.stringify(apikeyCredentials), + identifier: 'github', + name: 'GitHub', + sourceType: 'builtin', + status: 'connected', + }); + // other user's connector must not leak + const otherModel = new ConnectorModel(serverDB, otherUserId, undefined, gateKeeper); + await otherModel.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + const rows = await model.query(); + + expect(rows).toHaveLength(1); + expect(rows[0].credentials).toEqual(apikeyCredentials); + expect(gateKeeper.decrypt).toHaveBeenCalledOnce(); + }); + + it('returns null credentials for rows without credentials', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + const rows = await model.query(); + + expect(rows[0].credentials).toBeNull(); + expect(gateKeeper.decrypt).not.toHaveBeenCalled(); + }); + + it('falls back to null credentials when decryption / JSON parsing fails', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + // write a row whose decrypted payload is not valid JSON + await serverDB.insert(userConnectors).values({ + credentials: 'enc:not-json', + identifier: 'broken', + name: 'Broken', + sourceType: 'custom', + status: 'error', + userId, + }); + + const rows = await model.query(); + + expect(rows[0].credentials).toBeNull(); + }); + + it('returns raw plaintext credentials when no gateKeeper is set', async () => { + const model = new ConnectorModel(serverDB, userId); + await model.create({ + credentials: JSON.stringify(apikeyCredentials), + identifier: 'custom', + name: 'Custom', + sourceType: 'custom', + status: 'connected', + }); + + const rows = await model.query(); + + expect(rows[0].credentials).toEqual(apikeyCredentials); + }); + }); + + describe('queryByIdentifiers', () => { + it('returns an empty array for an empty identifier list', async () => { + const model = new ConnectorModel(serverDB, userId); + expect(await model.queryByIdentifiers([])).toEqual([]); + }); + + it('returns only connectors matching the given identifiers', async () => { + const model = new ConnectorModel(serverDB, userId); + await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + await model.create({ + identifier: 'github', + name: 'GitHub', + sourceType: 'builtin', + status: 'connected', + }); + await model.create({ + identifier: 'slack', + name: 'Slack', + sourceType: 'builtin', + status: 'connected', + }); + + const rows = await model.queryByIdentifiers(['linear', 'slack']); + + expect(rows.map((r) => r.identifier).sort()).toEqual(['linear', 'slack']); + }); + }); + + describe('findById', () => { + it('returns the decrypted connector by id', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + const created = await model.create({ + credentials: JSON.stringify(apikeyCredentials), + identifier: 'github', + name: 'GitHub', + sourceType: 'builtin', + status: 'connected', + }); + + const found = await model.findById(created.id); + + expect(found?.id).toBe(created.id); + expect(found?.credentials).toEqual(apikeyCredentials); + }); + + it('returns null when the id does not exist', async () => { + const model = new ConnectorModel(serverDB, userId); + expect(await model.findById('00000000-0000-0000-0000-000000000000')).toBeNull(); + }); + + it('returns null when the connector belongs to another user', async () => { + const otherModel = new ConnectorModel(serverDB, otherUserId); + const created = await otherModel.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + const model = new ConnectorModel(serverDB, userId); + expect(await model.findById(created.id)).toBeNull(); + }); + }); + + describe('update', () => { + it('updates non-credential fields without touching credentials', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + const created = await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + await model.update(created.id, { name: 'Linear Renamed' }); + + const [row] = await serverDB + .select() + .from(userConnectors) + .where(eq(userConnectors.id, created.id)); + expect(row.name).toBe('Linear Renamed'); + expect(gateKeeper.encrypt).not.toHaveBeenCalled(); + }); + + it('encrypts credentials when provided in the patch', async () => { + const model = new ConnectorModel(serverDB, userId, undefined, gateKeeper); + const created = await model.create({ + identifier: 'github', + name: 'GitHub', + sourceType: 'builtin', + status: 'connected', + }); + + const newCredentials = JSON.stringify({ token: 'bearer-token', type: 'bearer' }); + await model.update(created.id, { credentials: newCredentials }); + + expect(gateKeeper.encrypt).toHaveBeenCalledWith(newCredentials); + const found = await model.findById(created.id); + expect(found?.credentials).toEqual({ token: 'bearer-token', type: 'bearer' }); + }); + + it('does not update connectors owned by another user', async () => { + const otherModel = new ConnectorModel(serverDB, otherUserId); + const created = await otherModel.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + const model = new ConnectorModel(serverDB, userId); + await model.update(created.id, { name: 'Hacked' }); + + const [row] = await serverDB + .select() + .from(userConnectors) + .where(eq(userConnectors.id, created.id)); + expect(row.name).toBe('Linear'); + }); + }); + + describe('updateStatus', () => { + it('updates the connector status', async () => { + const model = new ConnectorModel(serverDB, userId); + const created = await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + await model.updateStatus(created.id, 'error'); + + const found = await model.findById(created.id); + expect(found?.status).toBe('error'); + }); + }); + + describe('delete', () => { + it('deletes a connector owned by the user', async () => { + const model = new ConnectorModel(serverDB, userId); + const created = await model.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + await model.delete(created.id); + + expect(await model.findById(created.id)).toBeNull(); + }); + + it('does not delete connectors owned by another user', async () => { + const otherModel = new ConnectorModel(serverDB, otherUserId); + const created = await otherModel.create({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + }); + + const model = new ConnectorModel(serverDB, userId); + await model.delete(created.id); + + expect(await otherModel.findById(created.id)).not.toBeNull(); + }); + }); +}); diff --git a/packages/database/src/models/__tests__/connectorTool.test.ts b/packages/database/src/models/__tests__/connectorTool.test.ts new file mode 100644 index 0000000000..5e4d3c114d --- /dev/null +++ b/packages/database/src/models/__tests__/connectorTool.test.ts @@ -0,0 +1,359 @@ +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getTestDB } from '../../core/getTestDB'; +import type { NewUserConnector } from '../../schemas'; +import { + ConnectorToolPermission, + ToolCRUDType, + userConnectors, + userConnectorTools, + users, + workspaces, +} from '../../schemas'; +import type { LobeChatDatabase } from '../../type'; +import { ConnectorToolModel, type SyncToolInput } from '../connectorTool'; + +const serverDB: LobeChatDatabase = await getTestDB(); + +const userId = 'connector-tool-user'; +const otherUserId = 'connector-tool-other-user'; +const workspaceId = 'connector-tool-workspace'; + +let connectorId: string; +let otherConnectorId: string; +let workspaceConnectorId: string; + +const baseConnector = (overrides: Partial): NewUserConnector => ({ + identifier: 'linear', + name: 'Linear', + sourceType: 'builtin', + status: 'connected', + userId, + ...overrides, +}); + +const insertConnector = async (overrides: Partial): Promise => { + const [row] = await serverDB.insert(userConnectors).values(baseConnector(overrides)).returning(); + return row.id; +}; + +beforeEach(async () => { + await serverDB.delete(users); + await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]); + await serverDB + .insert(workspaces) + .values({ id: workspaceId, name: 'WS', primaryOwnerId: userId, slug: 'ws' }); + + // Personal-mode connector for `userId` + connectorId = await insertConnector({ identifier: 'linear' }); + // Another user's connector + otherConnectorId = await insertConnector({ identifier: 'github', userId: otherUserId }); + // Workspace-scoped connector + workspaceConnectorId = await insertConnector({ identifier: 'slack', workspaceId }); +}); + +afterEach(async () => { + await serverDB.delete(users); +}); + +const tool = (overrides: Partial): SyncToolInput => ({ + crudType: ToolCRUDType.read, + toolName: 'search', + ...overrides, +}); + +describe('ConnectorToolModel', () => { + describe('upsertMany', () => { + it('returns early without inserting when the tool list is empty', async () => { + const model = new ConnectorToolModel(serverDB, userId); + + await model.upsertMany(connectorId, []); + + const rows = await serverDB.select().from(userConnectorTools); + expect(rows).toHaveLength(0); + }); + + it('inserts tools with defaults applied for omitted optional fields', async () => { + const model = new ConnectorToolModel(serverDB, userId); + + await model.upsertMany(connectorId, [tool({ toolName: 'search' })]); + + const rows = await model.queryByConnector(connectorId); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.toolName).toBe('search'); + expect(row.userId).toBe(userId); + expect(row.workspaceId).toBeNull(); + expect(row.userConnectorId).toBe(connectorId); + expect(row.crudType).toBe(ToolCRUDType.read); + // omitted optional fields default to null + expect(row.description).toBeNull(); + expect(row.displayName).toBeNull(); + expect(row.inputSchema).toBeNull(); + expect(row.outputSchema).toBeNull(); + expect(row.renderConfig).toBeNull(); + // default permission is `auto` + expect(row.permission).toBe(ConnectorToolPermission.auto); + expect(row.isWorkArtifact).toBe(false); + }); + + it('persists all provided manifest fields and the default permission override', async () => { + const model = new ConnectorToolModel(serverDB, userId); + + await model.upsertMany(connectorId, [ + tool({ + crudType: ToolCRUDType.write, + defaultPermission: ConnectorToolPermission.needs_approval, + description: 'Create an issue', + displayName: 'Create Issue', + inputSchema: { type: 'object' }, + outputSchema: { type: 'string' }, + renderConfig: { streaming: true }, + toolName: 'create_issue', + }), + ]); + + const [row] = await model.queryByConnector(connectorId); + expect(row.crudType).toBe(ToolCRUDType.write); + expect(row.permission).toBe(ConnectorToolPermission.needs_approval); + expect(row.description).toBe('Create an issue'); + expect(row.displayName).toBe('Create Issue'); + expect(row.inputSchema).toEqual({ type: 'object' }); + expect(row.outputSchema).toEqual({ type: 'string' }); + expect(row.renderConfig).toEqual({ streaming: true }); + }); + + it('inserts multiple tools at once', async () => { + const model = new ConnectorToolModel(serverDB, userId); + + await model.upsertMany(connectorId, [ + tool({ toolName: 'search' }), + tool({ toolName: 'create_issue', crudType: ToolCRUDType.write }), + ]); + + const rows = await model.queryByConnector(connectorId); + expect(rows.map((r) => r.toolName).sort()).toEqual(['create_issue', 'search']); + }); + + it('overwrites manifest fields but preserves user-controlled fields on conflict', async () => { + const model = new ConnectorToolModel(serverDB, userId); + + // initial insert + await model.upsertMany(connectorId, [ + tool({ + description: 'old description', + displayName: 'Old Name', + toolName: 'search', + }), + ]); + + const [initial] = await model.queryByConnector(connectorId); + // user changes permission + work-artifact flag (user-controlled) + await serverDB + .update(userConnectorTools) + .set({ isWorkArtifact: true, permission: ConnectorToolPermission.disabled }) + .where(eq(userConnectorTools.id, initial.id)); + + // re-sync the same tool with new manifest fields and a different default permission + await model.upsertMany(connectorId, [ + tool({ + defaultPermission: ConnectorToolPermission.needs_approval, + description: 'new description', + displayName: 'New Name', + toolName: 'search', + }), + ]); + + const rows = await model.queryByConnector(connectorId); + // still a single row (upsert, not duplicate insert) + expect(rows).toHaveLength(1); + const row = rows[0]; + // manifest fields overwritten + expect(row.description).toBe('new description'); + expect(row.displayName).toBe('New Name'); + // user-controlled fields preserved + expect(row.permission).toBe(ConnectorToolPermission.disabled); + expect(row.isWorkArtifact).toBe(true); + }); + + it('writes workspaceId when the model is workspace-scoped', async () => { + const model = new ConnectorToolModel(serverDB, userId, workspaceId); + + await model.upsertMany(workspaceConnectorId, [tool({ toolName: 'search' })]); + + const [row] = await serverDB + .select() + .from(userConnectorTools) + .where(eq(userConnectorTools.userConnectorId, workspaceConnectorId)); + expect(row.workspaceId).toBe(workspaceId); + }); + }); + + describe('updatePermission', () => { + it('updates the permission of a tool owned by the user', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [tool({ toolName: 'search' })]); + const [created] = await model.queryByConnector(connectorId); + + await model.updatePermission(created.id, ConnectorToolPermission.disabled); + + const [row] = await serverDB + .select() + .from(userConnectorTools) + .where(eq(userConnectorTools.id, created.id)); + expect(row.permission).toBe(ConnectorToolPermission.disabled); + }); + + it('does not update a tool owned by another user', async () => { + const otherModel = new ConnectorToolModel(serverDB, otherUserId); + await otherModel.upsertMany(otherConnectorId, [tool({ toolName: 'search' })]); + const [created] = await otherModel.queryByConnector(otherConnectorId); + + const model = new ConnectorToolModel(serverDB, userId); + await model.updatePermission(created.id, ConnectorToolPermission.disabled); + + const [row] = await serverDB + .select() + .from(userConnectorTools) + .where(eq(userConnectorTools.id, created.id)); + // unchanged — still the default `auto` + expect(row.permission).toBe(ConnectorToolPermission.auto); + }); + }); + + describe('queryByConnector', () => { + it('returns only the tools of the given connector for the current user', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [ + tool({ toolName: 'search' }), + tool({ toolName: 'create_issue', crudType: ToolCRUDType.write }), + ]); + + // another user's tool on another connector must not leak + const otherModel = new ConnectorToolModel(serverDB, otherUserId); + await otherModel.upsertMany(otherConnectorId, [tool({ toolName: 'leak' })]); + + const rows = await model.queryByConnector(connectorId); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.userId === userId)).toBe(true); + }); + + it('returns an empty array for a connector with no tools', async () => { + const model = new ConnectorToolModel(serverDB, userId); + expect(await model.queryByConnector(connectorId)).toEqual([]); + }); + }); + + describe('queryByConnectorIds', () => { + it('returns an empty array for an empty id list', async () => { + const model = new ConnectorToolModel(serverDB, userId); + expect(await model.queryByConnectorIds([])).toEqual([]); + }); + + it('excludes disabled tools', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [ + tool({ toolName: 'enabled' }), + tool({ defaultPermission: ConnectorToolPermission.disabled, toolName: 'disabled' }), + tool({ defaultPermission: ConnectorToolPermission.needs_approval, toolName: 'needs' }), + ]); + + const rows = await model.queryByConnectorIds([connectorId]); + expect(rows.map((r) => r.toolName).sort()).toEqual(['enabled', 'needs']); + }); + + it('does not leak tools owned by another user', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [tool({ toolName: 'mine' })]); + + const otherModel = new ConnectorToolModel(serverDB, otherUserId); + await otherModel.upsertMany(otherConnectorId, [tool({ toolName: 'theirs' })]); + + // even when explicitly asking for the other user's connector id, ownership filters it out + const rows = await model.queryByConnectorIds([connectorId, otherConnectorId]); + expect(rows.map((r) => r.toolName)).toEqual(['mine']); + }); + }); + + describe('queryAllByConnectorIds', () => { + it('returns an empty array for an empty id list', async () => { + const model = new ConnectorToolModel(serverDB, userId); + expect(await model.queryAllByConnectorIds([])).toEqual([]); + }); + + it('includes disabled tools', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [ + tool({ toolName: 'enabled' }), + tool({ defaultPermission: ConnectorToolPermission.disabled, toolName: 'disabled' }), + ]); + + const rows = await model.queryAllByConnectorIds([connectorId]); + expect(rows.map((r) => r.toolName).sort()).toEqual(['disabled', 'enabled']); + }); + + it('does not leak tools owned by another user', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [tool({ toolName: 'mine' })]); + + const otherModel = new ConnectorToolModel(serverDB, otherUserId); + await otherModel.upsertMany(otherConnectorId, [ + tool({ defaultPermission: ConnectorToolPermission.disabled, toolName: 'theirs' }), + ]); + + const rows = await model.queryAllByConnectorIds([connectorId, otherConnectorId]); + expect(rows.map((r) => r.toolName)).toEqual(['mine']); + }); + }); + + describe('findByToolName', () => { + it('returns the tool matching the name for the current user', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [ + tool({ toolName: 'search' }), + tool({ toolName: 'create_issue', crudType: ToolCRUDType.write }), + ]); + + const found = await model.findByToolName('create_issue'); + expect(found?.toolName).toBe('create_issue'); + expect(found?.userId).toBe(userId); + }); + + it('returns undefined when no tool matches the name', async () => { + const model = new ConnectorToolModel(serverDB, userId); + await model.upsertMany(connectorId, [tool({ toolName: 'search' })]); + + expect(await model.findByToolName('missing')).toBeUndefined(); + }); + + it('returns undefined when the matching tool belongs to another user', async () => { + const otherModel = new ConnectorToolModel(serverDB, otherUserId); + await otherModel.upsertMany(otherConnectorId, [tool({ toolName: 'theirs' })]); + + const model = new ConnectorToolModel(serverDB, userId); + expect(await model.findByToolName('theirs')).toBeUndefined(); + }); + }); + + describe('ownership in workspace mode', () => { + it('isolates personal-mode tools from workspace-scoped queries', async () => { + // personal tool (workspaceId IS NULL) + const personalModel = new ConnectorToolModel(serverDB, userId); + await personalModel.upsertMany(connectorId, [tool({ toolName: 'personal' })]); + + // workspace tool + const wsModel = new ConnectorToolModel(serverDB, userId, workspaceId); + await wsModel.upsertMany(workspaceConnectorId, [tool({ toolName: 'ws' })]); + + const wsRows = await wsModel.queryByConnectorIds([connectorId, workspaceConnectorId]); + expect(wsRows.map((r) => r.toolName)).toEqual(['ws']); + + const personalRows = await personalModel.queryByConnectorIds([ + connectorId, + workspaceConnectorId, + ]); + expect(personalRows.map((r) => r.toolName)).toEqual(['personal']); + }); + }); +}); diff --git a/packages/database/src/models/__tests__/file.test.ts b/packages/database/src/models/__tests__/file.test.ts index 2bb788df85..e9265fd33d 100644 --- a/packages/database/src/models/__tests__/file.test.ts +++ b/packages/database/src/models/__tests__/file.test.ts @@ -20,6 +20,7 @@ import { sessions, topics, users, + workspaces, } from '../../schemas'; import type { LobeChatDatabase } from '../../type'; import { FileModel } from '../file'; @@ -1690,4 +1691,208 @@ describe('FileModel', () => { expect(result).toEqual([]); }); }); + + describe('updateGlobalFile', () => { + it('should update url and metadata of a global file by hashId', async () => { + await fileModel.createGlobalFile({ + hashId: 'update-hash', + fileType: 'text/plain', + size: 100, + url: 'https://example.com/old.txt', + metadata: { version: 1 }, + creator: userId, + }); + + await fileModel.updateGlobalFile('update-hash', { + url: 'https://example.com/new.txt', + metadata: { version: 2 }, + }); + + const updated = await serverDB.query.globalFiles.findFirst({ + where: eq(globalFiles.hashId, 'update-hash'), + }); + + expect(updated?.url).toBe('https://example.com/new.txt'); + expect(updated?.metadata).toEqual({ version: 2 }); + }); + + it('should support running inside a provided transaction', async () => { + await fileModel.createGlobalFile({ + hashId: 'trx-update-hash', + fileType: 'text/plain', + size: 100, + url: 'https://example.com/old.txt', + metadata: { version: 1 }, + creator: userId, + }); + + await serverDB.transaction(async (trx) => { + await fileModel.updateGlobalFile( + 'trx-update-hash', + { url: 'https://example.com/trx.txt' }, + trx, + ); + }); + + const updated = await serverDB.query.globalFiles.findFirst({ + where: eq(globalFiles.hashId, 'trx-update-hash'), + }); + + expect(updated?.url).toBe('https://example.com/trx.txt'); + }); + }); + + describe('transferTo', () => { + const targetWorkspaceId = 'transfer-target-ws'; + + beforeEach(async () => { + await serverDB.insert(workspaces).values({ + id: targetWorkspaceId, + name: 'Target WS', + slug: 'transfer-target-ws', + primaryOwnerId: userId, + }); + }); + + it('should transfer ownership of a file and re-point knowledge base links', async () => { + await serverDB.insert(files).values({ + id: 'transfer-file-1', + name: 'transfer.txt', + fileType: 'text/plain', + size: 10, + url: 'k-transfer', + userId, + }); + await serverDB.insert(knowledgeBaseFiles).values({ + fileId: 'transfer-file-1', + knowledgeBaseId: knowledgeBase.id, + userId, + }); + + const result = await fileModel.transferTo('transfer-file-1', targetWorkspaceId, 'user2'); + + expect(result).toEqual({ fileId: 'transfer-file-1' }); + + const file = await serverDB.query.files.findFirst({ + where: eq(files.id, 'transfer-file-1'), + }); + expect(file?.userId).toBe('user2'); + expect(file?.workspaceId).toBe(targetWorkspaceId); + + const kbLink = await serverDB.query.knowledgeBaseFiles.findFirst({ + where: eq(knowledgeBaseFiles.fileId, 'transfer-file-1'), + }); + expect(kbLink?.userId).toBe('user2'); + }); + + it('should support transferring to a null (personal) workspace', async () => { + await serverDB.insert(files).values({ + id: 'transfer-file-2', + name: 'transfer2.txt', + fileType: 'text/plain', + size: 10, + url: 'k-transfer2', + userId, + }); + + await fileModel.transferTo('transfer-file-2', null, 'user2'); + + const file = await serverDB.query.files.findFirst({ + where: eq(files.id, 'transfer-file-2'), + }); + expect(file?.userId).toBe('user2'); + expect(file?.workspaceId).toBeNull(); + }); + + it('should throw when the file does not exist or is not owned', async () => { + await expect( + fileModel.transferTo('non-existent-file', targetWorkspaceId, 'user2'), + ).rejects.toThrow('File not found'); + }); + }); + + describe('copyToWorkspace', () => { + const targetWorkspaceId = 'copy-target-ws'; + + beforeEach(async () => { + await serverDB.insert(workspaces).values({ + id: targetWorkspaceId, + name: 'Copy Target WS', + slug: 'copy-target-ws', + primaryOwnerId: userId, + }); + }); + + it('should clone a file row into the target scope and reset index task ids', async () => { + await fileModel.createGlobalFile({ + hashId: 'copy-hash', + fileType: 'text/plain', + size: 42, + url: 'k-copy', + creator: userId, + }); + await serverDB.insert(files).values({ + id: 'copy-file-1', + name: 'copy.txt', + fileType: 'text/plain', + fileHash: 'copy-hash', + size: 42, + url: 'k-copy', + metadata: { original: true }, + chunkTaskId: null, + embeddingTaskId: null, + userId, + }); + + const result = await fileModel.copyToWorkspace('copy-file-1', targetWorkspaceId, 'user2'); + + expect(result.fileId).toBeDefined(); + expect(result.fileId).not.toBe('copy-file-1'); + + const copied = await serverDB.query.files.findFirst({ + where: eq(files.id, result.fileId), + }); + expect(copied?.userId).toBe('user2'); + expect(copied?.workspaceId).toBe(targetWorkspaceId); + expect(copied?.fileHash).toBe('copy-hash'); + expect(copied?.name).toBe('copy.txt'); + expect(copied?.size).toBe(42); + expect(copied?.chunkTaskId).toBeNull(); + expect(copied?.embeddingTaskId).toBeNull(); + expect(copied?.parentId).toBeNull(); + expect((copied?.metadata as Record).duplicatedFrom).toBe('copy-file-1'); + expect((copied?.metadata as Record).original).toBe(true); + + // Original file remains untouched + const original = await serverDB.query.files.findFirst({ + where: eq(files.id, 'copy-file-1'), + }); + expect(original?.userId).toBe(userId); + }); + + it('should support copying to a null (personal) workspace', async () => { + await serverDB.insert(files).values({ + id: 'copy-file-2', + name: 'copy2.txt', + fileType: 'text/plain', + size: 1, + url: 'k-copy2', + userId, + }); + + const result = await fileModel.copyToWorkspace('copy-file-2', null, 'user2'); + + const copied = await serverDB.query.files.findFirst({ + where: eq(files.id, result.fileId), + }); + expect(copied?.workspaceId).toBeNull(); + expect(copied?.userId).toBe('user2'); + }); + + it('should throw when the source file does not exist or is not owned', async () => { + await expect( + fileModel.copyToWorkspace('non-existent-file', targetWorkspaceId, 'user2'), + ).rejects.toThrow('File not found'); + }); + }); }); diff --git a/packages/database/src/models/__tests__/messengerAccountLink.test.ts b/packages/database/src/models/__tests__/messengerAccountLink.test.ts index 8dcb10a8f0..10ecc3d65d 100644 --- a/packages/database/src/models/__tests__/messengerAccountLink.test.ts +++ b/packages/database/src/models/__tests__/messengerAccountLink.test.ts @@ -308,5 +308,192 @@ describe('MessengerAccountLinkModel', () => { expect(acme?.activeAgentId).toBeNull(); expect(beta?.activeAgentId).toBe(agentA); }); + + it('returns undefined when there is no matching row', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + const updated = await model.setActiveAgent('telegram', agentA, null); + expect(updated).toBeUndefined(); + }); + }); + + describe('delete', () => { + it('removes the user-owned link by id', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + const link = await model.upsertForPlatform({ + activeAgentId: agentA, + platform: 'telegram', + platformUserId: 'tg-del', + }); + + await model.delete(link.id); + + const remaining = await model.list(); + expect(remaining).toHaveLength(0); + }); + + it('does not delete a link owned by another user (ownership scoping)', async () => { + const ownerB = new MessengerAccountLinkModel(serverDB, userB); + const link = await ownerB.upsertForPlatform({ + activeAgentId: agentB, + platform: 'telegram', + platformUserId: 'tg-owned-by-b', + }); + + // userA tries to delete userB's link by id — ownership() must block it. + await new MessengerAccountLinkModel(serverDB, userA).delete(link.id); + + const stillThere = await ownerB.findByPlatform('telegram'); + expect(stillThere?.id).toBe(link.id); + }); + }); + + describe('deleteByPlatform', () => { + it('deletes all of the user links for a platform when no tenant is given', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + await model.upsertForPlatform({ + platform: 'slack', + platformUserId: 'U_IN_ACME', + tenantId: 'T_ACME', + }); + await model.upsertForPlatform({ + platform: 'slack', + platformUserId: 'U_IN_BETA', + tenantId: 'T_BETA', + }); + await model.upsertForPlatform({ + platform: 'telegram', + platformUserId: 'tg-keep', + }); + + await model.deleteByPlatform('slack'); + + const links = await model.list(); + expect(links).toHaveLength(1); + expect(links[0].platform).toBe('telegram'); + }); + + it('deletes only the targeted tenant row when tenantId is provided', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + await model.upsertForPlatform({ + platform: 'slack', + platformUserId: 'U_IN_ACME', + tenantId: 'T_ACME', + }); + await model.upsertForPlatform({ + platform: 'slack', + platformUserId: 'U_IN_BETA', + tenantId: 'T_BETA', + }); + + await model.deleteByPlatform('slack', 'T_ACME'); + + const acme = await model.findByPlatform('slack', 'T_ACME'); + const beta = await model.findByPlatform('slack', 'T_BETA'); + expect(acme).toBeUndefined(); + expect(beta?.tenantId).toBe('T_BETA'); + }); + + it('only deletes the calling user links (ownership scoping)', async () => { + await new MessengerAccountLinkModel(serverDB, userB).upsertForPlatform({ + activeAgentId: agentB, + platform: 'telegram', + platformUserId: 'tg-b', + }); + + // userA has no telegram link; deleting by platform must not touch userB's. + await new MessengerAccountLinkModel(serverDB, userA).deleteByPlatform('telegram'); + + const stillB = await MessengerAccountLinkModel.findByPlatformUser( + serverDB, + 'telegram', + 'tg-b', + ); + expect(stillB?.userId).toBe(userB); + }); + }); + + describe('setActiveAgentById (static)', () => { + it('updates the active agent for the given link id', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + const link = await model.upsertForPlatform({ + activeAgentId: agentA, + platform: 'telegram', + platformUserId: 'tg-static-agent', + }); + + const updated = await MessengerAccountLinkModel.setActiveAgentById( + serverDB, + link.id, + workspaceAgentA, + ); + expect(updated?.activeAgentId).toBe(workspaceAgentA); + // It only touches activeAgentId — workspaceId (scope) is left as-is. + expect(updated?.workspaceId).toBe(link.workspaceId); + }); + + it('clears the active agent when passed null', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + const link = await model.upsertForPlatform({ + activeAgentId: agentA, + platform: 'telegram', + platformUserId: 'tg-static-clear', + }); + + const updated = await MessengerAccountLinkModel.setActiveAgentById(serverDB, link.id, null); + expect(updated?.activeAgentId).toBeNull(); + }); + + it('returns undefined for an unknown link id', async () => { + const updated = await MessengerAccountLinkModel.setActiveAgentById( + serverDB, + '00000000-0000-0000-0000-000000000000', + agentA, + ); + expect(updated).toBeUndefined(); + }); + }); + + describe('setActiveScope (static)', () => { + it('moves the link to a workspace scope with the provided agent', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + const link = await model.upsertForPlatform({ + activeAgentId: agentA, + platform: 'telegram', + platformUserId: 'tg-scope-static', + workspaceId: null, + }); + + const updated = await MessengerAccountLinkModel.setActiveScope( + serverDB, + link.id, + workspaceA, + workspaceAgentA, + ); + expect(updated?.workspaceId).toBe(workspaceA); + expect(updated?.activeAgentId).toBe(workspaceAgentA); + }); + + it('defaults the agent to null when none is passed (personal scope, no agents)', async () => { + const model = new MessengerAccountLinkModel(serverDB, userA); + const link = await model.upsertForPlatform({ + activeAgentId: agentA, + platform: 'telegram', + platformUserId: 'tg-scope-default', + workspaceId: workspaceA, + }); + + const updated = await MessengerAccountLinkModel.setActiveScope(serverDB, link.id, null); + expect(updated?.workspaceId).toBeNull(); + expect(updated?.activeAgentId).toBeNull(); + }); + + it('returns undefined for an unknown link id', async () => { + const updated = await MessengerAccountLinkModel.setActiveScope( + serverDB, + '00000000-0000-0000-0000-000000000000', + workspaceA, + ); + expect(updated).toBeUndefined(); + }); }); }); diff --git a/packages/database/src/models/__tests__/notification.test.ts b/packages/database/src/models/__tests__/notification.test.ts index 6700471246..ce7ade993e 100644 --- a/packages/database/src/models/__tests__/notification.test.ts +++ b/packages/database/src/models/__tests__/notification.test.ts @@ -1,7 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTestDB } from '../../core/getTestDB'; import { NotificationModel } from '../../models/notification'; -import { notifications } from '../../schemas/notification'; +import { notificationDeliveries, notifications } from '../../schemas/notification'; +import { users } from '../../schemas/user'; import type { LobeChatDatabase } from '../../type'; describe('NotificationModel', () => { @@ -41,3 +44,320 @@ describe('NotificationModel', () => { }); }); }); + +// ─── Integration tests against a real PGlite database ───────────── +const serverDB: LobeChatDatabase = await getTestDB(); + +const userId = 'notification-user'; +const otherUserId = 'notification-other-user'; + +const baseNotification = (overrides: Record = {}) => ({ + category: 'workspace', + content: 'You have a new notification.', + title: 'New notification', + type: 'workspace_member_added', + ...overrides, +}); + +beforeEach(async () => { + vi.clearAllMocks(); + await serverDB.delete(users); + await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]); +}); + +afterEach(async () => { + await serverDB.delete(users); +}); + +describe('NotificationModel (integration)', () => { + describe('create', () => { + it('creates a user-scoped notification and returns the row', async () => { + const model = new NotificationModel(serverDB, userId); + + const result = await model.create(baseNotification({ dedupeKey: 'dedupe-1' })); + + expect(result).not.toBeNull(); + expect(result!.id).toBeDefined(); + expect(result!.userId).toBe(userId); + expect(result!.isRead).toBe(false); + expect(result!.isArchived).toBe(false); + expect(result!.dedupeKey).toBe('dedupe-1'); + }); + + it('returns null on dedupe conflict (same userId + dedupeKey)', async () => { + const model = new NotificationModel(serverDB, userId); + + const first = await model.create(baseNotification({ dedupeKey: 'dup-key' })); + expect(first).not.toBeNull(); + + const second = await model.create( + baseNotification({ dedupeKey: 'dup-key', title: 'Second attempt' }), + ); + expect(second).toBeNull(); + + const rows = await model.list(); + expect(rows).toHaveLength(1); + }); + + it('allows duplicate creates when dedupeKey is null', async () => { + const model = new NotificationModel(serverDB, userId); + + const first = await model.create(baseNotification()); + const second = await model.create(baseNotification()); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + + const rows = await model.list(); + expect(rows).toHaveLength(2); + }); + + it('does not conflict across different users with the same dedupeKey', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + + const a = await model.create(baseNotification({ dedupeKey: 'shared' })); + const b = await otherModel.create(baseNotification({ dedupeKey: 'shared' })); + + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + }); + }); + + describe('list', () => { + it('returns only the current user notifications, newest first', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + + const older = await model.create(baseNotification({ title: 'Older' })); + // ensure a distinct, later createdAt + await new Promise((resolve) => setTimeout(resolve, 5)); + const newer = await model.create(baseNotification({ title: 'Newer' })); + await otherModel.create(baseNotification({ title: 'Other user' })); + + const rows = await model.list(); + + expect(rows).toHaveLength(2); + expect(rows[0].id).toBe(newer!.id); + expect(rows[1].id).toBe(older!.id); + }); + + it('excludes archived notifications', async () => { + const model = new NotificationModel(serverDB, userId); + const kept = await model.create(baseNotification({ title: 'Kept' })); + const archived = await model.create(baseNotification({ title: 'Archived' })); + + await model.archive(archived!.id); + + const rows = await model.list(); + expect(rows.map((r) => r.id)).toEqual([kept!.id]); + }); + + it('filters by unreadOnly', async () => { + const model = new NotificationModel(serverDB, userId); + const read = await model.create(baseNotification({ title: 'Read' })); + const unread = await model.create(baseNotification({ title: 'Unread' })); + + await model.markAsRead([read!.id]); + + const rows = await model.list({ unreadOnly: true }); + expect(rows.map((r) => r.id)).toEqual([unread!.id]); + }); + + it('filters by category', async () => { + const model = new NotificationModel(serverDB, userId); + await model.create(baseNotification({ category: 'workspace', title: 'WS' })); + const budget = await model.create(baseNotification({ category: 'budget', title: 'Budget' })); + + const rows = await model.list({ category: 'budget' }); + expect(rows.map((r) => r.id)).toEqual([budget!.id]); + }); + + it('respects the limit option', async () => { + const model = new NotificationModel(serverDB, userId); + await model.create(baseNotification({ title: 'A' })); + await model.create(baseNotification({ title: 'B' })); + await model.create(baseNotification({ title: 'C' })); + + const rows = await model.list({ limit: 2 }); + expect(rows).toHaveLength(2); + }); + + it('paginates with a cursor', async () => { + const model = new NotificationModel(serverDB, userId); + const first = await model.create(baseNotification({ title: '1' })); + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = await model.create(baseNotification({ title: '2' })); + await new Promise((resolve) => setTimeout(resolve, 5)); + const third = await model.create(baseNotification({ title: '3' })); + + const page1 = await model.list({ limit: 1 }); + expect(page1.map((r) => r.id)).toEqual([third!.id]); + + const page2 = await model.list({ cursor: third!.id, limit: 1 }); + expect(page2.map((r) => r.id)).toEqual([second!.id]); + + const page3 = await model.list({ cursor: second!.id, limit: 1 }); + expect(page3.map((r) => r.id)).toEqual([first!.id]); + }); + + it('ignores a cursor that does not belong to the user', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + await model.create(baseNotification({ title: 'Mine' })); + const otherRow = await otherModel.create(baseNotification({ title: 'Theirs' })); + + // cursor belongs to another user → cursor condition is skipped, all own rows returned + const rows = await model.list({ cursor: otherRow!.id }); + expect(rows).toHaveLength(1); + }); + + it('returns an empty array when the user has no notifications', async () => { + const model = new NotificationModel(serverDB, userId); + expect(await model.list()).toEqual([]); + }); + }); + + describe('getUnreadCount', () => { + it('counts only unread, non-archived notifications for the user', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + + const read = await model.create(baseNotification({ title: 'Read' })); + await model.create(baseNotification({ title: 'Unread 1' })); + await model.create(baseNotification({ title: 'Unread 2' })); + const archived = await model.create(baseNotification({ title: 'Archived' })); + await otherModel.create(baseNotification({ title: 'Other' })); + + await model.markAsRead([read!.id]); + await model.archive(archived!.id); + + expect(await model.getUnreadCount()).toBe(2); + }); + + it('returns 0 when there are no notifications', async () => { + const model = new NotificationModel(serverDB, userId); + expect(await model.getUnreadCount()).toBe(0); + }); + }); + + describe('markAsRead', () => { + it('marks the given notifications as read', async () => { + const model = new NotificationModel(serverDB, userId); + const a = await model.create(baseNotification({ title: 'A' })); + const b = await model.create(baseNotification({ title: 'B' })); + + await model.markAsRead([a!.id]); + + const rows = await model.list(); + const aRow = rows.find((r) => r.id === a!.id); + const bRow = rows.find((r) => r.id === b!.id); + expect(aRow!.isRead).toBe(true); + expect(bRow!.isRead).toBe(false); + }); + + it('returns early (no-op) for an empty id list', async () => { + const model = new NotificationModel(serverDB, userId); + await model.create(baseNotification({ title: 'A' })); + + const result = await model.markAsRead([]); + expect(result).toBeUndefined(); + expect(await model.getUnreadCount()).toBe(1); + }); + + it('does not mark notifications owned by another user', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + const otherRow = await otherModel.create(baseNotification({ title: 'Theirs' })); + + await model.markAsRead([otherRow!.id]); + + expect(await otherModel.getUnreadCount()).toBe(1); + }); + }); + + describe('markAllAsRead', () => { + it('marks all unread notifications for the user as read', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + await model.create(baseNotification({ title: 'A' })); + await model.create(baseNotification({ title: 'B' })); + await otherModel.create(baseNotification({ title: 'Other' })); + + await model.markAllAsRead(); + + expect(await model.getUnreadCount()).toBe(0); + expect(await otherModel.getUnreadCount()).toBe(1); + }); + }); + + describe('archive', () => { + it('archives a single notification owned by the user', async () => { + const model = new NotificationModel(serverDB, userId); + const row = await model.create(baseNotification({ title: 'A' })); + + await model.archive(row!.id); + + const [persisted] = await serverDB + .select() + .from(notifications) + .where(eq(notifications.id, row!.id)); + expect(persisted.isArchived).toBe(true); + }); + + it('does not archive a notification owned by another user', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + const otherRow = await otherModel.create(baseNotification({ title: 'Theirs' })); + + await model.archive(otherRow!.id); + + const [persisted] = await serverDB + .select() + .from(notifications) + .where(eq(notifications.id, otherRow!.id)); + expect(persisted.isArchived).toBe(false); + }); + }); + + describe('archiveAll', () => { + it('archives all non-archived notifications for the user', async () => { + const model = new NotificationModel(serverDB, userId); + const otherModel = new NotificationModel(serverDB, otherUserId); + await model.create(baseNotification({ title: 'A' })); + await model.create(baseNotification({ title: 'B' })); + await otherModel.create(baseNotification({ title: 'Other' })); + + await model.archiveAll(); + + expect(await model.list()).toEqual([]); + expect(await otherModel.list()).toHaveLength(1); + }); + }); + + describe('createDelivery', () => { + it('creates a delivery row for a notification', async () => { + const model = new NotificationModel(serverDB, userId); + const notification = await model.create(baseNotification({ title: 'A' })); + + const delivery = await model.createDelivery({ + channel: 'email', + notificationId: notification!.id, + providerMessageId: 'resend-123', + sentAt: new Date(), + status: 'sent', + }); + + expect(delivery.id).toBeDefined(); + expect(delivery.notificationId).toBe(notification!.id); + expect(delivery.channel).toBe('email'); + expect(delivery.status).toBe('sent'); + + const [persisted] = await serverDB + .select() + .from(notificationDeliveries) + .where(eq(notificationDeliveries.id, delivery.id)); + expect(persisted.providerMessageId).toBe('resend-123'); + }); + }); +}); diff --git a/packages/database/src/models/__tests__/rbac.test.ts b/packages/database/src/models/__tests__/rbac.test.ts index 49dfc6e6c7..d8f4e3e2ca 100644 --- a/packages/database/src/models/__tests__/rbac.test.ts +++ b/packages/database/src/models/__tests__/rbac.test.ts @@ -201,5 +201,236 @@ describe('RbacModel — workspace scope', () => { expect(await rbac.hasPermission(ownerCode)).toBe(true); }); + + it('accepts a bare userId string and resolves grants for that user', async () => { + // legacy call form: hasPermission(code, userId) — normalizeScope's string branch. + const rbac = new RbacModel(serverDB, otherUserId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.OWNER, + userId, + workspaceId: workspaceAId, + }); + + // model bound to otherUserId, but the string arg overrides the target. + expect(await rbac.hasPermission(ownerCode, userId)).toBe(true); + // otherUserId itself has no grant. + expect(await rbac.hasPermission(ownerCode)).toBe(false); + }); + + it('falls back to the constructor userId when no scope arg is given', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.OWNER, + userId, + workspaceId: workspaceAId, + }); + + // no arg at all → targets this.userId. + expect(await rbac.getUserPermissions()).toContain(ownerCode); + }); + }); + + describe('getUserPermissionDetails', () => { + it('returns ordered detail rows with role/category/name metadata', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.OWNER, + userId, + workspaceId: workspaceAId, + }); + + const details = await rbac.getUserPermissionDetails({ workspaceId: workspaceAId }); + + expect(details.length).toBeGreaterThan(0); + // every row carries the full shape + for (const row of details) { + expect(row.permissionCode).toBeTruthy(); + expect(row.permissionName).toBeTruthy(); + expect(row.category).toBeTruthy(); + expect(row.roleName).toBe(WORKSPACE_SYSTEM_ROLES.OWNER); + } + // the owner-update code is present + expect(details.some((r) => r.permissionCode === ownerCode)).toBe(true); + // ordered by (category, code) ascending + const sorted = [...details].sort((a, b) => + a.category === b.category + ? a.permissionCode.localeCompare(b.permissionCode) + : a.category.localeCompare(b.category), + ); + expect(details.map((r) => r.permissionCode)).toEqual(sorted.map((r) => r.permissionCode)); + }); + + it('returns an empty array for a user with no grants', async () => { + const rbac = new RbacModel(serverDB, otherUserId); + expect(await rbac.getUserPermissionDetails({ workspaceId: workspaceAId })).toEqual([]); + }); + }); + + describe('hasAnyPermission', () => { + it('returns false immediately for an empty permission list (no DB hit)', async () => { + const rbac = new RbacModel(serverDB, userId); + expect(await rbac.hasAnyPermission([])).toBe(false); + }); + + it('returns true when at least one code is granted', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.VIEWER, + userId, + workspaceId: workspaceAId, + }); + + // viewer lacks ownerCode but has memberCode → OR is satisfied. + expect( + await rbac.hasAnyPermission([ownerCode, memberCode], { workspaceId: workspaceAId }), + ).toBe(true); + }); + + it('returns false when none of the codes are granted', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.VIEWER, + userId, + workspaceId: workspaceAId, + }); + + expect( + await rbac.hasAnyPermission(['nonexistent:perm:all'], { workspaceId: workspaceAId }), + ).toBe(false); + }); + }); + + describe('hasAllPermissions', () => { + it('returns true immediately for an empty permission list', async () => { + const rbac = new RbacModel(serverDB, userId); + expect(await rbac.hasAllPermissions([])).toBe(true); + }); + + it('returns true when every code is granted', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.OWNER, + userId, + workspaceId: workspaceAId, + }); + + // owner has both codes. + expect( + await rbac.hasAllPermissions([ownerCode, memberCode], { workspaceId: workspaceAId }), + ).toBe(true); + }); + + it('returns false when at least one code is missing', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.VIEWER, + userId, + workspaceId: workspaceAId, + }); + + // viewer has memberCode but not ownerCode → AND fails. + expect( + await rbac.hasAllPermissions([ownerCode, memberCode], { workspaceId: workspaceAId }), + ).toBe(false); + }); + }); + + describe('getUserRoles', () => { + it('returns the active roles granted to the user in a workspace', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.OWNER, + userId, + workspaceId: workspaceAId, + }); + + const userRoleList = await rbac.getUserRoles({ workspaceId: workspaceAId }); + expect(userRoleList).toHaveLength(1); + expect(userRoleList[0].name).toBe(WORKSPACE_SYSTEM_ROLES.OWNER); + expect(userRoleList[0].workspaceId).toBe(workspaceAId); + expect(userRoleList[0].isActive).toBe(true); + }); + + it('returns an empty array when the user has no grants', async () => { + const rbac = new RbacModel(serverDB, otherUserId); + expect(await rbac.getUserRoles({ workspaceId: workspaceAId })).toEqual([]); + }); + + it('does not return roles granted in a different workspace', async () => { + const rbac = new RbacModel(serverDB, userId); + await rbac.assignWorkspaceRole({ + roleName: WORKSPACE_SYSTEM_ROLES.OWNER, + userId, + workspaceId: workspaceBId, + }); + + expect(await rbac.getUserRoles({ workspaceId: workspaceAId })).toEqual([]); + expect(await rbac.getUserRoles({ workspaceId: workspaceBId })).toHaveLength(1); + }); + }); + + describe('updateUserRoles', () => { + const roleIdFor = async (name: string, workspaceId: string): Promise => { + const row = await serverDB.query.roles.findFirst({ + where: and(eq(roles.name, name), eq(roles.workspaceId, workspaceId)), + }); + if (!row) throw new Error(`role ${name} not seeded`); + return row.id; + }; + + it('throws when one of the role ids does not exist', async () => { + const rbac = new RbacModel(serverDB, userId); + const validId = await roleIdFor(WORKSPACE_SYSTEM_ROLES.OWNER, workspaceAId); + + await expect(rbac.updateUserRoles(userId, [validId, 'missing-role-id'])).rejects.toThrow( + /missing-role-id do not exist/, + ); + }); + + it('replaces the user existing roles with the provided set', async () => { + const rbac = new RbacModel(serverDB, userId); + const ownerId = await roleIdFor(WORKSPACE_SYSTEM_ROLES.OWNER, workspaceAId); + const memberId = await roleIdFor(WORKSPACE_SYSTEM_ROLES.MEMBER, workspaceAId); + + // pre-seed an existing grant that should be wiped by the replace. + await serverDB.insert(userRoles).values({ roleId: ownerId, userId }); + + await rbac.updateUserRoles(userId, [memberId]); + + const grants = await serverDB.query.userRoles.findMany({ + where: eq(userRoles.userId, userId), + }); + expect(grants).toHaveLength(1); + expect(grants[0].roleId).toBe(memberId); + }); + + it('removes all roles when given an empty array', async () => { + const rbac = new RbacModel(serverDB, userId); + const ownerId = await roleIdFor(WORKSPACE_SYSTEM_ROLES.OWNER, workspaceAId); + await serverDB.insert(userRoles).values({ roleId: ownerId, userId }); + + await rbac.updateUserRoles(userId, []); + + const grants = await serverDB.query.userRoles.findMany({ + where: eq(userRoles.userId, userId), + }); + expect(grants).toHaveLength(0); + }); + + it('only touches the target user roles, leaving others intact', async () => { + const rbac = new RbacModel(serverDB, userId); + const ownerId = await roleIdFor(WORKSPACE_SYSTEM_ROLES.OWNER, workspaceAId); + const memberId = await roleIdFor(WORKSPACE_SYSTEM_ROLES.MEMBER, workspaceAId); + + await serverDB.insert(userRoles).values({ roleId: ownerId, userId: otherUserId }); + + await rbac.updateUserRoles(userId, [memberId]); + + const otherGrants = await serverDB.query.userRoles.findMany({ + where: eq(userRoles.userId, otherUserId), + }); + expect(otherGrants).toHaveLength(1); + expect(otherGrants[0].roleId).toBe(ownerId); + }); }); }); diff --git a/packages/database/src/models/__tests__/session.test.ts b/packages/database/src/models/__tests__/session.test.ts index eade2e4afa..7d5bad75d9 100644 --- a/packages/database/src/models/__tests__/session.test.ts +++ b/packages/database/src/models/__tests__/session.test.ts @@ -283,7 +283,9 @@ describe('SessionModel', () => { }); }); - describe('queryByKeyword', () => { + // BM25 search requires pg_search extension (ParadeDB), not available in PGlite + const isServerDB = process.env.TEST_SERVER_DB === '1'; + describe.skipIf(!isServerDB)('queryByKeyword', () => { it('should return an empty array if keyword is empty', async () => { const result = await sessionModel.queryByKeyword(''); expect(result).toEqual([]); diff --git a/packages/database/src/models/__tests__/task.test.ts b/packages/database/src/models/__tests__/task.test.ts index 0fff2a661c..668d453180 100644 --- a/packages/database/src/models/__tests__/task.test.ts +++ b/packages/database/src/models/__tests__/task.test.ts @@ -1,8 +1,9 @@ // @vitest-environment node +import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getTestDB } from '../../core/getTestDB'; -import { agents, briefs, documents, topics, users } from '../../schemas'; +import { agents, briefs, documents, tasks, topics, users, workspaces } from '../../schemas'; import type { LobeChatDatabase } from '../../type'; import { TaskModel } from '../task'; @@ -285,6 +286,18 @@ describe('TaskModel', () => { expect(tasks[0].parentTaskId).toBeNull(); }); + it('should filter by a specific parentTaskId', async () => { + const model = new TaskModel(serverDB, userId); + const parent = await model.create({ instruction: 'Parent' }); + await model.create({ instruction: 'Child 1', parentTaskId: parent.id }); + await model.create({ instruction: 'Child 2', parentTaskId: parent.id }); + await model.create({ instruction: 'Unrelated' }); + + const { tasks, total } = await model.list({ parentTaskId: parent.id }); + expect(total).toBe(2); + expect(tasks.every((t) => t.parentTaskId === parent.id)).toBe(true); + }); + it('should paginate results', async () => { const model = new TaskModel(serverDB, userId); for (let i = 0; i < 5; i++) { @@ -371,6 +384,33 @@ describe('TaskModel', () => { expect(backlogP2.offset).toBe(2); }); + it('should filter root tasks only (parentTaskId null)', async () => { + const model = new TaskModel(serverDB, userId); + const parent = await model.create({ instruction: 'Parent' }); + await model.create({ instruction: 'Child', parentTaskId: parent.id }); + + const result = await model.groupList({ + groups: [{ key: 'backlog', statuses: ['backlog'] }], + parentTaskId: null, + }); + expect(result[0].tasks.every((t) => t.parentTaskId === null)).toBe(true); + expect(result[0].total).toBe(1); + }); + + it('should filter by a specific parentTaskId', async () => { + const model = new TaskModel(serverDB, userId); + const parent = await model.create({ instruction: 'Parent' }); + await model.create({ instruction: 'Child 1', parentTaskId: parent.id }); + await model.create({ instruction: 'Child 2', parentTaskId: parent.id }); + + const result = await model.groupList({ + groups: [{ key: 'backlog', statuses: ['backlog'] }], + parentTaskId: parent.id, + }); + expect(result[0].total).toBe(2); + expect(result[0].tasks.every((t) => t.parentTaskId === parent.id)).toBe(true); + }); + it('should filter by assigneeAgentId', async () => { const agentId = await createAgent('group-list-agent'); const model = new TaskModel(serverDB, userId); @@ -1114,4 +1154,505 @@ describe('TaskModel', () => { expect(childReview!.rubrics[0].type).toBe('llm-rubric'); }); }); + + describe('findByIds', () => { + it('should return empty array for empty input', async () => { + const model = new TaskModel(serverDB, userId); + const result = await model.findByIds([]); + expect(result).toEqual([]); + }); + + it('should find tasks by ids and respect ownership', async () => { + const model1 = new TaskModel(serverDB, userId); + const model2 = new TaskModel(serverDB, userId2); + const a = await model1.create({ instruction: 'A' }); + const b = await model1.create({ instruction: 'B' }); + const other = await model2.create({ instruction: 'Other user' }); + + const found = await model1.findByIds([a.id, b.id, other.id]); + // other.id belongs to user2, must be excluded + expect(found.map((t) => t.id).sort()).toEqual([a.id, b.id].sort()); + }); + }); + + describe('resolve', () => { + it('should resolve by task id when value starts with task_', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + // Real ids start with task_ (idGenerator('tasks')) + expect(task.id.startsWith('task_')).toBe(true); + + const resolved = await model.resolve(task.id); + expect(resolved!.id).toBe(task.id); + }); + + it('should resolve by identifier (uppercased) otherwise', async () => { + const model = new TaskModel(serverDB, userId); + await model.create({ instruction: 'Test' }); + + const resolved = await model.resolve('t-1'); + expect(resolved!.identifier).toBe('T-1'); + }); + + it('should return null when identifier not found', async () => { + const model = new TaskModel(serverDB, userId); + const resolved = await model.resolve('T-999'); + expect(resolved).toBeNull(); + }); + }); + + describe('update early return', () => { + it('should return current task when no fields to update', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + + const result = await model.update(task.id, {}); + expect(result!.id).toBe(task.id); + }); + + it('should return null when updating non-existent task', async () => { + const model = new TaskModel(serverDB, userId); + const result = await model.update('task_does_not_exist', { name: 'X' }); + expect(result).toBeNull(); + }); + }); + + describe('reorder', () => { + it('should batch update sortOrder', async () => { + const model = new TaskModel(serverDB, userId); + const a = await model.create({ instruction: 'A' }); + const b = await model.create({ instruction: 'B' }); + + await model.reorder([ + { id: a.id, sortOrder: 5 }, + { id: b.id, sortOrder: 2 }, + ]); + + const fa = await model.findById(a.id); + const fb = await model.findById(b.id); + expect(fa!.sortOrder).toBe(5); + expect(fb!.sortOrder).toBe(2); + }); + }); + + describe('findAllDescendants', () => { + it('should collect all descendants breadth-first', async () => { + const model = new TaskModel(serverDB, userId); + const root = await model.create({ instruction: 'Root' }); + const child = await model.create({ instruction: 'Child', parentTaskId: root.id }); + const grandchild = await model.create({ + instruction: 'Grandchild', + parentTaskId: child.id, + }); + + const all = await model.findAllDescendants(root.id); + expect(all.map((t) => t.id).sort()).toEqual([child.id, grandchild.id].sort()); + }); + + it('should return empty when no descendants', async () => { + const model = new TaskModel(serverDB, userId); + const root = await model.create({ instruction: 'Lonely' }); + const all = await model.findAllDescendants(root.id); + expect(all).toHaveLength(0); + }); + }); + + describe('getTreeAgentIdsForTaskIds', () => { + it('should return empty object for empty input', async () => { + const model = new TaskModel(serverDB, userId); + const result = await model.getTreeAgentIdsForTaskIds([]); + expect(result).toEqual({}); + }); + + it('should collect assignee + creator agents across the full tree', async () => { + const model = new TaskModel(serverDB, userId); + const agentA = await createAgent('tree-agent-a'); + const agentB = await createAgent('tree-agent-b'); + + const root = await model.create({ + assigneeAgentId: agentA, + instruction: 'Root', + }); + const child = await model.create({ + createdByAgentId: agentB, + instruction: 'Child', + parentTaskId: root.id, + }); + + // Query from the child id — walks up to root then down across whole tree + const result = await model.getTreeAgentIdsForTaskIds([child.id]); + expect(result[child.id].sort()).toEqual([agentA, agentB].sort()); + }); + }); + + describe('batchUpdateStatus', () => { + it('should update status for multiple tasks and respect ownership', async () => { + const model1 = new TaskModel(serverDB, userId); + const model2 = new TaskModel(serverDB, userId2); + const a = await model1.create({ instruction: 'A' }); + const b = await model1.create({ instruction: 'B' }); + const other = await model2.create({ instruction: 'Other' }); + + const count = await model1.batchUpdateStatus([a.id, b.id, other.id], 'completed'); + expect(count).toBe(2); + + expect((await model1.findById(a.id))!.status).toBe('completed'); + expect((await model2.findById(other.id))!.status).toBe('backlog'); + }); + }); + + describe('updateContext', () => { + it('should deep merge into context jsonb', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + + await model.updateContext(task.id, { scheduler: { consecutiveFailures: 1 } }); + const updated = await model.updateContext(task.id, { + scheduler: { tickMessageId: 'm1' }, + }); + + const ctx = updated!.context as Record; + expect(ctx.scheduler.consecutiveFailures).toBe(1); + expect(ctx.scheduler.tickMessageId).toBe('m1'); + }); + + it('should return null for non-existent task', async () => { + const model = new TaskModel(serverDB, userId); + const result = await model.updateContext('task_missing', { a: 1 }); + expect(result).toBeNull(); + }); + }); + + describe('getCheckpointConfig / getReviewConfig fallbacks', () => { + it('getCheckpointConfig returns empty object when config has no checkpoint', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + expect(model.getCheckpointConfig(task)).toEqual({}); + }); + + it('getReviewConfig returns undefined when no review config', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + expect(model.getReviewConfig(task)).toBeUndefined(); + }); + }); + + describe('static getScheduledTasks', () => { + it('should return schedule-mode tasks that are not terminal/paused/running', async () => { + const model = new TaskModel(serverDB, userId); + const eligible = await model.create({ + automationMode: 'schedule', + instruction: 'Eligible', + schedulePattern: '0 * * * *', + }); + // Running excluded + const running = await model.create({ + automationMode: 'schedule', + instruction: 'Running', + schedulePattern: '0 * * * *', + }); + await model.updateStatus(running.id, 'running', { startedAt: new Date() }); + // No schedulePattern excluded + await model.create({ automationMode: 'schedule', instruction: 'No pattern' }); + // Not schedule mode excluded + await model.create({ instruction: 'Manual' }); + + const result = await TaskModel.getScheduledTasks(serverDB); + const ids = result.map((t) => t.id); + expect(ids).toContain(eligible.id); + expect(ids).not.toContain(running.id); + }); + }); + + describe('static findStuckTasks', () => { + it('should find running tasks whose heartbeat timed out', async () => { + const model = new TaskModel(serverDB, userId); + const stuck = await model.create({ instruction: 'Stuck' }); + await model.update(stuck.id, { + heartbeatTimeout: 1, + status: 'running', + }); + // Force a stale heartbeat in the past + await serverDB + .update(tasks) + .set({ lastHeartbeatAt: new Date(Date.now() - 60_000) }) + .where(eq(tasks.id, stuck.id)); + + // Healthy running task with a fresh heartbeat + const healthy = await model.create({ instruction: 'Healthy' }); + await model.update(healthy.id, { heartbeatTimeout: 600, status: 'running' }); + await model.updateHeartbeat(healthy.id); + + const result = await TaskModel.findStuckTasks(serverDB); + const ids = result.map((t) => t.id); + expect(ids).toContain(stuck.id); + expect(ids).not.toContain(healthy.id); + }); + }); + + describe('updateComment', () => { + it('should update comment content and editorData', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + const comment = await model.addComment({ + authorUserId: userId, + content: 'Original', + taskId: task.id, + userId, + }); + + const updated = await model.updateComment(comment.id, 'Edited', { + editorData: { foo: 'bar' }, + }); + expect(updated!.content).toBe('Edited'); + expect(updated!.editorData).toEqual({ foo: 'bar' }); + }); + + it('should update content without editorData', async () => { + const model = new TaskModel(serverDB, userId); + const task = await model.create({ instruction: 'Test' }); + const comment = await model.addComment({ + authorUserId: userId, + content: 'Original', + taskId: task.id, + userId, + }); + + const updated = await model.updateComment(comment.id, 'Edited only'); + expect(updated!.content).toBe('Edited only'); + }); + + it('should not update comment owned by another user', async () => { + const model1 = new TaskModel(serverDB, userId); + const model2 = new TaskModel(serverDB, userId2); + const task = await model1.create({ instruction: 'Test' }); + const comment = await model1.addComment({ + authorUserId: userId, + content: 'Original', + taskId: task.id, + userId, + }); + + const updated = await model2.updateComment(comment.id, 'Hacked'); + expect(updated).toBeUndefined(); + }); + }); + + describe('getTreePinnedDocuments', () => { + const insertDoc = async (title: string, parentId: string | null = null) => { + const [doc] = await serverDB + .insert(documents) + .values({ + content: '', + fileType: 'text/plain', + parentId, + source: 'test', + sourceType: 'file', + title, + totalCharCount: 5, + totalLineCount: 1, + userId, + }) + .returning(); + return doc; + }; + + it('should build nodeMap and tree across the task tree', async () => { + const model = new TaskModel(serverDB, userId); + const root = await model.create({ instruction: 'Root' }); + const child = await model.create({ instruction: 'Child', parentTaskId: root.id }); + + const parentDoc = await insertDoc('Parent Doc'); + const childDoc = await insertDoc('Child Doc', parentDoc.id); + const childTaskDoc = await insertDoc('From child task'); + + await model.pinDocument(root.id, parentDoc.id); + await model.pinDocument(root.id, childDoc.id); + await model.pinDocument(child.id, childTaskDoc.id); + + const data = await model.getTreePinnedDocuments(root.id); + + expect(Object.keys(data.nodeMap).sort()).toEqual( + [parentDoc.id, childDoc.id, childTaskDoc.id].sort(), + ); + // childDoc nests under parentDoc; parentDoc + childTaskDoc are top-level + expect(data.tree).toHaveLength(2); + const parentNode = data.tree.find((n) => n.id === parentDoc.id)!; + expect(parentNode.children.map((c) => c.id)).toEqual([childDoc.id]); + + // sourceTaskIdentifier is null for the root task, populated for child task + expect(data.nodeMap[parentDoc.id].sourceTaskIdentifier).toBeNull(); + expect(data.nodeMap[childTaskDoc.id].sourceTaskIdentifier).toBe(child.identifier); + // Title fallback covered separately; here titles exist + expect(data.nodeMap[parentDoc.id].title).toBe('Parent Doc'); + }); + + it('should return empty data when no documents pinned', async () => { + const model = new TaskModel(serverDB, userId); + const root = await model.create({ instruction: 'Root' }); + const data = await model.getTreePinnedDocuments(root.id); + expect(data.nodeMap).toEqual({}); + expect(data.tree).toEqual([]); + }); + + it('should scope to the workspace when model is workspace-scoped', async () => { + const wsId = 'task-tree-docs-ws'; + await serverDB + .insert(workspaces) + .values({ id: wsId, name: 'Docs WS', primaryOwnerId: userId, slug: 'task-tree-docs-ws' }) + .onConflictDoNothing(); + + const wsModel = new TaskModel(serverDB, userId, wsId); + const root = await wsModel.create({ instruction: 'Root' }); + const doc = await insertDoc('WS Doc'); + await wsModel.pinDocument(root.id, doc.id); + + const data = await wsModel.getTreePinnedDocuments(root.id); + expect(Object.keys(data.nodeMap)).toEqual([doc.id]); + }); + }); + + describe('transferTo', () => { + const wsId = 'task-target-ws'; + + beforeEach(async () => { + await serverDB + .insert(workspaces) + .values({ id: wsId, name: 'Target WS', primaryOwnerId: userId, slug: 'task-target-ws' }) + .onConflictDoNothing(); + }); + + it('should throw when task not found', async () => { + const model = new TaskModel(serverDB, userId); + await expect(model.transferTo('task_missing', wsId, userId)).rejects.toThrow( + 'Task not found', + ); + }); + + it('should transfer subtree to a workspace, reallocating identifiers', async () => { + const model = new TaskModel(serverDB, userId); + const agentId = await createAgent('transfer-agent'); + const root = await model.create({ assigneeAgentId: agentId, instruction: 'Root' }); + const child = await model.create({ instruction: 'Child', parentTaskId: root.id }); + const doc = await serverDB + .insert(documents) + .values({ + content: '', + fileType: 'text/plain', + source: 'test', + sourceType: 'file', + title: 'D', + totalCharCount: 0, + totalLineCount: 0, + userId, + }) + .returning(); + await model.pinDocument(root.id, doc[0].id); + await model.addComment({ + authorUserId: userId, + content: 'c', + taskId: root.id, + userId, + }); + + const { taskIds } = await model.transferTo(root.id, wsId, userId); + expect(taskIds.sort()).toEqual([root.id, child.id].sort()); + + // Now scoped to the workspace + const wsModel = new TaskModel(serverDB, userId, wsId); + const movedRoot = await wsModel.findById(root.id); + expect(movedRoot!.workspaceId).toBe(wsId); + // Cross-workspace move clears assigneeAgentId and currentTopicId + expect(movedRoot!.assigneeAgentId).toBeNull(); + expect(movedRoot!.currentTopicId).toBeNull(); + expect(movedRoot!.identifier).toBe('T-1'); + + // Child tables moved too + const movedDocs = await wsModel.getPinnedDocuments(root.id); + expect(movedDocs).toHaveLength(1); + const movedComments = await wsModel.getComments(root.id); + expect(movedComments).toHaveLength(1); + + // No longer visible in the personal scope + expect(await model.findById(root.id)).toBeNull(); + }); + + it('should preserve assigneeAgentId when target workspace equals current scope', async () => { + // Start scoped to a workspace, transfer within the same workspace. + const wsModel = new TaskModel(serverDB, userId, wsId); + const agentId = await createAgent('same-ws-agent'); + const root = await wsModel.create({ assigneeAgentId: agentId, instruction: 'Root' }); + + await wsModel.transferTo(root.id, wsId, userId); + const moved = await wsModel.findById(root.id); + expect(moved!.assigneeAgentId).toBe(agentId); + }); + }); + + describe('copyToWorkspace', () => { + const wsId = 'task-copy-ws'; + + beforeEach(async () => { + await serverDB + .insert(workspaces) + .values({ id: wsId, name: 'Copy WS', primaryOwnerId: userId, slug: 'task-copy-ws' }) + .onConflictDoNothing(); + }); + + it('should throw when task not found', async () => { + const model = new TaskModel(serverDB, userId); + await expect(model.copyToWorkspace('task_missing', wsId, userId)).rejects.toThrow( + 'Task not found', + ); + }); + + it('should deep clone subtree with fresh ids and reset lifecycle', async () => { + const model = new TaskModel(serverDB, userId); + const agentId = await createAgent('copy-agent'); + const root = await model.create({ + assigneeAgentId: agentId, + config: { review: { enabled: true } }, + instruction: 'Root', + name: 'Root name', + }); + await model.updateStatus(root.id, 'completed', { completedAt: new Date() }); + const child = await model.create({ instruction: 'Child', parentTaskId: root.id }); + + const { rootId } = await model.copyToWorkspace(root.id, wsId, userId); + expect(rootId).not.toBe(root.id); + + const wsModel = new TaskModel(serverDB, userId, wsId); + const clonedRoot = await wsModel.findById(rootId); + expect(clonedRoot!.workspaceId).toBe(wsId); + expect(clonedRoot!.name).toBe('Root name'); + // Lifecycle reset on the clone + expect(clonedRoot!.status).toBe('backlog'); + expect(clonedRoot!.assigneeAgentId).toBeNull(); + expect(clonedRoot!.totalTopics).toBe(0); + // Provenance recorded in context + expect((clonedRoot!.context as Record).duplicatedFrom).toBe(root.id); + // Config preserved + expect((clonedRoot!.config as Record).review.enabled).toBe(true); + + // The child was cloned and re-parented under the cloned root + const clonedChildren = await wsModel.findSubtasks(rootId); + expect(clonedChildren).toHaveLength(1); + expect(clonedChildren[0].id).not.toBe(child.id); + + // Original subtree untouched in the personal scope + expect((await model.findById(root.id))!.status).toBe('completed'); + }); + + it('should clone a workspace task into the personal scope (null target)', async () => { + const wsModel = new TaskModel(serverDB, userId, wsId); + const root = await wsModel.create({ instruction: 'WS Root' }); + + const { rootId } = await wsModel.copyToWorkspace(root.id, null, userId); + + const personalModel = new TaskModel(serverDB, userId); + const cloned = await personalModel.findById(rootId); + expect(cloned).not.toBeNull(); + expect(cloned!.workspaceId).toBeNull(); + }); + }); }); diff --git a/packages/database/src/models/__tests__/taskTopic.test.ts b/packages/database/src/models/__tests__/taskTopic.test.ts index d7b0f81452..e122f4422a 100644 --- a/packages/database/src/models/__tests__/taskTopic.test.ts +++ b/packages/database/src/models/__tests__/taskTopic.test.ts @@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getTestDB } from '../../core/getTestDB'; -import { taskTopics, topics, users } from '../../schemas'; +import { tasks, taskTopics, topics, users } from '../../schemas'; import type { LobeChatDatabase } from '../../type'; import { TaskModel } from '../task'; import { TaskTopicModel } from '../taskTopic'; @@ -432,6 +432,124 @@ describe('TaskTopicModel', () => { }); }); + describe('findByTopicId', () => { + it('returns the taskTopic row matching the topicId', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const task = await taskModel.create({ instruction: 'Test' }); + await createTopic('tpc_find'); + + await topicModel.add(task.id, 'tpc_find', { operationId: 'op_find', seq: 7 }); + + const row = await topicModel.findByTopicId('tpc_find'); + expect(row).not.toBeNull(); + expect(row!.taskId).toBe(task.id); + expect(row!.topicId).toBe('tpc_find'); + expect(row!.seq).toBe(7); + expect(row!.operationId).toBe('op_find'); + }); + + it('returns null when no row matches', async () => { + const topicModel = new TaskTopicModel(serverDB, userId); + const row = await topicModel.findByTopicId('tpc_missing'); + expect(row).toBeNull(); + }); + + it('does not return a row owned by a different user', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const otherTopicModel = new TaskTopicModel(serverDB, userId2); + const task = await taskModel.create({ instruction: 'Test' }); + await createTopic('tpc_owned'); + + await topicModel.add(task.id, 'tpc_owned', { seq: 1 }); + + expect(await topicModel.findByTopicId('tpc_owned')).not.toBeNull(); + expect(await otherTopicModel.findByTopicId('tpc_owned')).toBeNull(); + }); + }); + + describe('updateOperationId', () => { + it('updates the operationId for the task/topic pair', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const task = await taskModel.create({ instruction: 'Test' }); + await createTopic('tpc_op'); + + await topicModel.add(task.id, 'tpc_op', { operationId: 'op_old', seq: 1 }); + await topicModel.updateOperationId(task.id, 'tpc_op', 'op_new'); + + const row = await topicModel.findByTopicId('tpc_op'); + expect(row!.operationId).toBe('op_new'); + }); + }); + + describe('findWithDetails', () => { + it('joins topic fields and orders by seq desc', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const task = await taskModel.create({ instruction: 'Test' }); + + await serverDB + .insert(topics) + .values([ + { id: 'tpc_d1', title: 'First', userId }, + { id: 'tpc_d2', title: 'Second', userId }, + ]) + .onConflictDoNothing(); + + await topicModel.add(task.id, 'tpc_d1', { operationId: 'op_d1', seq: 1 }); + await topicModel.add(task.id, 'tpc_d2', { operationId: 'op_d2', seq: 2 }); + await topicModel.updateStatus(task.id, 'tpc_d1', 'completed'); + await topicModel.updateReview(task.id, 'tpc_d1', { + iteration: 2, + passed: true, + score: 90, + scores: [{ rubricId: 'r1', score: 1 }], + }); + + const rows = await topicModel.findWithDetails(task.id); + expect(rows).toHaveLength(2); + // seq desc ordering + expect(rows[0].seq).toBe(2); + expect(rows[1].seq).toBe(1); + + const d1 = rows.find((r) => r.id === 'tpc_d1')!; + expect(d1.title).toBe('First'); + expect(d1.operationId).toBe('op_d1'); + expect(d1.status).toBe('completed'); + expect(d1.reviewIteration).toBe(2); + expect(d1.reviewPassed).toBe(1); + expect(d1.reviewScore).toBe(90); + expect(d1.createdAt).toBeInstanceOf(Date); + + const d2 = rows.find((r) => r.id === 'tpc_d2')!; + expect(d2.title).toBe('Second'); + }); + + it('returns an empty array when the task has no topics', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const task = await taskModel.create({ instruction: 'Test' }); + + const rows = await topicModel.findWithDetails(task.id); + expect(rows).toEqual([]); + }); + + it('does not return rows owned by a different user', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const otherTopicModel = new TaskTopicModel(serverDB, userId2); + const task = await taskModel.create({ instruction: 'Test' }); + await createTopic('tpc_dx'); + + await topicModel.add(task.id, 'tpc_dx', { seq: 1 }); + + expect(await topicModel.findWithDetails(task.id)).toHaveLength(1); + expect(await otherTopicModel.findWithDetails(task.id)).toHaveLength(0); + }); + }); + describe('remove', () => { it('should remove topic association', async () => { const taskModel = new TaskModel(serverDB, userId); @@ -447,6 +565,37 @@ describe('TaskTopicModel', () => { expect(topics).toHaveLength(0); }); + it('decrements tasks.totalTopics (floored at 0) on successful remove', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const task = await taskModel.create({ instruction: 'Test' }); + await createTopic('tpc_r1'); + await createTopic('tpc_r2'); + + await topicModel.add(task.id, 'tpc_r1', { seq: 1 }); + await topicModel.add(task.id, 'tpc_r2', { seq: 2 }); + // simulate the task counter having been incremented for the two topics + await serverDB.update(tasks).set({ totalTopics: 2 }).where(eq(tasks.id, task.id)); + + await topicModel.remove(task.id, 'tpc_r1'); + const afterFirst = ( + await serverDB.select().from(tasks).where(eq(tasks.id, task.id)).limit(1) + )[0]; + expect(afterFirst.totalTopics).toBe(1); + }); + + it('returns false and leaves the counter untouched when nothing matched', async () => { + const taskModel = new TaskModel(serverDB, userId); + const topicModel = new TaskTopicModel(serverDB, userId); + const task = await taskModel.create({ instruction: 'Test' }); + await serverDB.update(tasks).set({ totalTopics: 3 }).where(eq(tasks.id, task.id)); + + const removed = await topicModel.remove(task.id, 'tpc_nope'); + expect(removed).toBe(false); + const after = (await serverDB.select().from(tasks).where(eq(tasks.id, task.id)).limit(1))[0]; + expect(after.totalTopics).toBe(3); + }); + it('should not remove topics of other users', async () => { const taskModel = new TaskModel(serverDB, userId); const topicModel1 = new TaskTopicModel(serverDB, userId); diff --git a/packages/database/src/models/__tests__/verifyRubric.test.ts b/packages/database/src/models/__tests__/verifyRubric.test.ts index f95736951d..706324ffb7 100644 --- a/packages/database/src/models/__tests__/verifyRubric.test.ts +++ b/packages/database/src/models/__tests__/verifyRubric.test.ts @@ -1,8 +1,9 @@ // @vitest-environment node +import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getTestDB } from '../../core/getTestDB'; -import { users, verifyRubrics } from '../../schemas'; +import { users, verifyCriteria, verifyRubricCriteria, verifyRubrics } from '../../schemas'; import type { LobeChatDatabase } from '../../type'; import { VerifyRubricModel } from '../verifyRubric'; @@ -17,10 +18,20 @@ beforeEach(async () => { }); afterEach(async () => { + await serverDB.delete(verifyRubricCriteria); + await serverDB.delete(verifyCriteria); await serverDB.delete(verifyRubrics); await serverDB.delete(users); }); +const insertCriterion = async (ownerId: string, title: string) => { + const [row] = await serverDB + .insert(verifyCriteria) + .values({ title, userId: ownerId, verifierType: 'llm' }) + .returning(); + return row; +}; + describe('VerifyRubricModel config', () => { it('persists run-policy config on create and reads it back', async () => { const model = new VerifyRubricModel(serverDB, userId); @@ -60,3 +71,156 @@ describe('VerifyRubricModel config', () => { expect(asOther).toBeUndefined(); }); }); + +describe('VerifyRubricModel query / delete', () => { + it('lists rubrics for the owning user ordered by updatedAt desc', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const first = await model.create({ title: 'first' }); + const second = await model.create({ title: 'second' }); + + // Set explicit, well-separated timestamps so `query` ordering (desc updatedAt) + // is deterministic — relying on create/update timing can tie within the same ms. + await serverDB + .update(verifyRubrics) + .set({ updatedAt: new Date('2025-01-02T00:00:00Z') }) + .where(eq(verifyRubrics.id, first.id)); + await serverDB + .update(verifyRubrics) + .set({ updatedAt: new Date('2025-01-01T00:00:00Z') }) + .where(eq(verifyRubrics.id, second.id)); + + const list = await model.query(); + expect(list).toHaveLength(2); + expect(list[0].id).toBe(first.id); + expect(list[1].id).toBe(second.id); + }); + + it('does not list rubrics owned by another user', async () => { + await new VerifyRubricModel(serverDB, userId).create({ title: 'mine' }); + await new VerifyRubricModel(serverDB, otherUserId).create({ title: 'theirs' }); + + const list = await new VerifyRubricModel(serverDB, userId).query(); + expect(list).toHaveLength(1); + expect(list[0].title).toBe('mine'); + }); + + it('deletes a rubric owned by the user', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const created = await model.create({ title: 'to-delete' }); + + await model.delete(created.id); + + const found = await model.findById(created.id); + expect(found).toBeUndefined(); + }); + + it('does not delete a rubric owned by another user', async () => { + const created = await new VerifyRubricModel(serverDB, userId).create({ title: 'mine' }); + + await new VerifyRubricModel(serverDB, otherUserId).delete(created.id); + + const found = await new VerifyRubricModel(serverDB, userId).findById(created.id); + expect(found?.id).toBe(created.id); + }); + + it('cascades junction rows when the rubric is deleted', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const rubric = await model.create({ title: 'with-criteria' }); + const c1 = await insertCriterion(userId, 'c1'); + await model.setCriteria(rubric.id, [{ criterionId: c1.id }]); + + await model.delete(rubric.id); + + const links = await serverDB.query.verifyRubricCriteria.findMany({ + where: eq(verifyRubricCriteria.rubricId, rubric.id), + }); + expect(links).toHaveLength(0); + }); +}); + +describe('VerifyRubricModel getCriteria / setCriteria', () => { + it('returns an empty array when the rubric has no criteria', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const rubric = await model.create({ title: 'empty' }); + + const result = await model.getCriteria(rubric.id); + expect(result).toEqual([]); + }); + + it('attaches criteria and resolves them ordered by sortOrder', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const rubric = await model.create({ title: 'ordered' }); + const a = await insertCriterion(userId, 'a'); + const b = await insertCriterion(userId, 'b'); + const c = await insertCriterion(userId, 'c'); + + await model.setCriteria(rubric.id, [ + { criterionId: b.id, sortOrder: 2 }, + { criterionId: a.id, sortOrder: 0 }, + { criterionId: c.id, sortOrder: 1 }, + ]); + + const resolved = await model.getCriteria(rubric.id); + expect(resolved.map((r) => r.title)).toEqual(['a', 'c', 'b']); + }); + + it('defaults sortOrder to the array index when omitted', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const rubric = await model.create({ title: 'default-order' }); + const a = await insertCriterion(userId, 'a'); + const b = await insertCriterion(userId, 'b'); + + await model.setCriteria(rubric.id, [{ criterionId: a.id }, { criterionId: b.id }]); + + const resolved = await model.getCriteria(rubric.id); + expect(resolved.map((r) => r.title)).toEqual(['a', 'b']); + }); + + it('replaces existing criteria idempotently', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const rubric = await model.create({ title: 'replace' }); + const a = await insertCriterion(userId, 'a'); + const b = await insertCriterion(userId, 'b'); + + await model.setCriteria(rubric.id, [{ criterionId: a.id }]); + await model.setCriteria(rubric.id, [{ criterionId: b.id }]); + + const resolved = await model.getCriteria(rubric.id); + expect(resolved.map((r) => r.title)).toEqual(['b']); + }); + + it('clears all criteria when given an empty set', async () => { + const model = new VerifyRubricModel(serverDB, userId); + const rubric = await model.create({ title: 'clear' }); + const a = await insertCriterion(userId, 'a'); + + await model.setCriteria(rubric.id, [{ criterionId: a.id }]); + await model.setCriteria(rubric.id, []); + + const resolved = await model.getCriteria(rubric.id); + expect(resolved).toEqual([]); + }); + + it('scopes getCriteria to the owning user', async () => { + const owner = new VerifyRubricModel(serverDB, userId); + const rubric = await owner.create({ title: 'scoped' }); + const a = await insertCriterion(userId, 'a'); + await owner.setCriteria(rubric.id, [{ criterionId: a.id }]); + + const asOther = await new VerifyRubricModel(serverDB, otherUserId).getCriteria(rubric.id); + expect(asOther).toEqual([]); + }); + + it('setCriteria from another user does not remove the owner junction rows', async () => { + const owner = new VerifyRubricModel(serverDB, userId); + const rubric = await owner.create({ title: 'isolation' }); + const a = await insertCriterion(userId, 'a'); + await owner.setCriteria(rubric.id, [{ criterionId: a.id }]); + + // other user clears with an empty set — userId-scoped delete leaves owner rows intact + await new VerifyRubricModel(serverDB, otherUserId).setCriteria(rubric.id, []); + + const resolved = await owner.getCriteria(rubric.id); + expect(resolved.map((r) => r.title)).toEqual(['a']); + }); +}); diff --git a/packages/database/src/models/__tests__/workspace.test.ts b/packages/database/src/models/__tests__/workspace.test.ts index 8ad77f999d..f7bf47061e 100644 --- a/packages/database/src/models/__tests__/workspace.test.ts +++ b/packages/database/src/models/__tests__/workspace.test.ts @@ -162,6 +162,244 @@ describe('WorkspaceModel', () => { await model.setGracePeriod(workspaceId, null); await expect(model.getSettings(workspaceId)).resolves.toEqual({ keep: true }); }); + + it('finds a workspace by id and by slug, and returns undefined when missing', async () => { + const workspaceId = await createWorkspace(); + const model = new WorkspaceModel(serverDB, ownerId); + + await expect(model.findById(workspaceId)).resolves.toMatchObject({ id: workspaceId }); + await expect(model.findBySlug(workspaceId)).resolves.toMatchObject({ slug: workspaceId }); + await expect(model.findById('missing')).resolves.toBeUndefined(); + await expect(model.findBySlug('missing')).resolves.toBeUndefined(); + }); + + it('lists only workspace ids where the user is the primary owner', async () => { + const workspaceId = await createWorkspace(); + + const owned = await new WorkspaceModel(serverDB, ownerId).listOwnedWorkspaceIds(); + expect(owned).toEqual([workspaceId]); + + // secondOwnerId is an owner member but not the primary owner + const secondOwned = await new WorkspaceModel(serverDB, secondOwnerId).listOwnedWorkspaceIds(); + expect(secondOwned).toEqual([]); + }); + + it('returns empty settings object when workspace does not exist', async () => { + await expect(new WorkspaceModel(serverDB, ownerId).getSettings('missing')).resolves.toEqual({}); + }); + + it('counts every active membership and excludes soft-deleted ones', async () => { + const workspaceId = await createWorkspace(); + + await expect(new WorkspaceModel(serverDB, ownerId).countUserMemberships()).resolves.toBe(1); + + await serverDB + .update(workspaceMembers) + .set({ deletedAt: new Date() }) + .where(eq(workspaceMembers.userId, memberId)); + await expect(new WorkspaceModel(serverDB, memberId).countUserMemberships()).resolves.toBe(0); + await expect(new WorkspaceModel(serverDB, outsiderId).countUserMemberships()).resolves.toBe(0); + + void workspaceId; + }); + + it('returns empty list when the user has no memberships', async () => { + await createWorkspace(); + await expect(new WorkspaceModel(serverDB, outsiderId).listUserWorkspaces()).resolves.toEqual( + [], + ); + }); + + it('falls back to viewer role when a workspace has no matching membership row', async () => { + const workspaceId = await createWorkspace(); + // Give outsider a membership with an unexpected role value to exercise the + // role lookup, then remove the membership row matching but keep workspace. + await serverDB.insert(workspaceMembers).values({ + role: 'viewer', + userId: outsiderId, + workspaceId, + }); + + const list = await new WorkspaceModel(serverDB, outsiderId).listUserWorkspaces(); + expect(list).toEqual([expect.objectContaining({ id: workspaceId, role: 'viewer' })]); + }); + + it('updates editable fields and bumps updatedAt', async () => { + const workspaceId = await createWorkspace(); + const model = new WorkspaceModel(serverDB, ownerId); + + await model.update(workspaceId, { description: 'updated', name: 'Renamed', slug: 'renamed' }); + + const workspace = await serverDB.query.workspaces.findFirst({ + where: eq(workspaces.id, workspaceId), + }); + expect(workspace).toMatchObject({ description: 'updated', name: 'Renamed', slug: 'renamed' }); + }); + + it('updates settings wholesale via updateSettings', async () => { + const workspaceId = await createWorkspace(); + const model = new WorkspaceModel(serverDB, ownerId); + + await model.updateSettings(workspaceId, { brandNew: true }); + await expect(model.getSettings(workspaceId)).resolves.toEqual({ brandNew: true }); + }); + + describe('transferPrimaryOwnership errors', () => { + it('rejects transferring to self', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, ownerId).transferPrimaryOwnership(workspaceId, ownerId), + ).rejects.toThrow('New primary owner must be a different user'); + }); + + it('rejects when the workspace does not exist', async () => { + await expect( + new WorkspaceModel(serverDB, ownerId).transferPrimaryOwnership('missing', secondOwnerId), + ).rejects.toThrow('Workspace not found'); + }); + + it('rejects when actor is not the primary owner', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, secondOwnerId).transferPrimaryOwnership(workspaceId, ownerId), + ).rejects.toThrow('Only the primary owner can transfer primary ownership'); + }); + + it('rejects when the target is not a member', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, ownerId).transferPrimaryOwnership(workspaceId, outsiderId), + ).rejects.toThrow('Target user must already be a member of the workspace'); + }); + }); + + describe('promoteToOwner', () => { + it('promotes a member to owner', async () => { + const workspaceId = await createWorkspace(); + const result = await new WorkspaceModel(serverDB, ownerId).promoteToOwner( + workspaceId, + memberId, + ); + + expect(result).toMatchObject({ role: 'owner', userId: memberId }); + + const membership = await serverDB.query.workspaceMembers.findFirst({ + where: eq(workspaceMembers.userId, memberId), + }); + expect(membership?.role).toBe('owner'); + }); + + it('is a no-op when the target is already an owner', async () => { + const workspaceId = await createWorkspace(); + const result = await new WorkspaceModel(serverDB, ownerId).promoteToOwner( + workspaceId, + secondOwnerId, + ); + expect(result).toMatchObject({ role: 'owner', userId: secondOwnerId }); + }); + + it('rejects when the actor is not an owner', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, memberId).promoteToOwner(workspaceId, memberId), + ).rejects.toThrow('Only an owner can promote other members to owner'); + }); + + it('rejects when the target is not a member', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, ownerId).promoteToOwner(workspaceId, outsiderId), + ).rejects.toThrow('Target user is not a member of this workspace'); + }); + }); + + describe('demoteFromOwner', () => { + it('demotes an owner to member', async () => { + const workspaceId = await createWorkspace(); + const result = await new WorkspaceModel(serverDB, ownerId).demoteFromOwner( + workspaceId, + secondOwnerId, + ); + + expect(result).toMatchObject({ role: 'member', userId: secondOwnerId }); + const membership = await serverDB.query.workspaceMembers.findFirst({ + where: eq(workspaceMembers.userId, secondOwnerId), + }); + expect(membership?.role).toBe('member'); + }); + + it('is a no-op when the target is not an owner', async () => { + const workspaceId = await createWorkspace(); + const result = await new WorkspaceModel(serverDB, ownerId).demoteFromOwner( + workspaceId, + memberId, + ); + expect(result).toMatchObject({ role: 'member', userId: memberId }); + }); + + it('rejects when the workspace does not exist', async () => { + await expect( + new WorkspaceModel(serverDB, ownerId).demoteFromOwner('missing', secondOwnerId), + ).rejects.toThrow('Workspace not found'); + }); + + it('rejects demoting the primary owner', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, ownerId).demoteFromOwner(workspaceId, ownerId), + ).rejects.toThrow('Cannot demote the primary owner'); + }); + + it('rejects when the actor is not an owner', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, memberId).demoteFromOwner(workspaceId, secondOwnerId), + ).rejects.toThrow('Only an owner can demote other owners'); + }); + + it('rejects when the target is not a member', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, ownerId).demoteFromOwner(workspaceId, outsiderId), + ).rejects.toThrow('Target user is not a member of this workspace'); + }); + }); + + it('counts other active owners excluding the given user', async () => { + const workspaceId = await createWorkspace(); + const model = new WorkspaceModel(serverDB, ownerId); + + // owners: ownerId, secondOwnerId. Excluding ownerId -> 1 other owner. + await expect(model.countOtherOwners(workspaceId, ownerId)).resolves.toBe(1); + + // soft-delete secondOwnerId membership -> 0 other owners. + await serverDB + .update(workspaceMembers) + .set({ deletedAt: new Date() }) + .where(eq(workspaceMembers.userId, secondOwnerId)); + await expect(model.countOtherOwners(workspaceId, ownerId)).resolves.toBe(0); + }); + + describe('downgradeToSolo and setGracePeriod errors', () => { + it('rejects downgradeToSolo when the workspace does not exist', async () => { + await expect( + new WorkspaceModel(serverDB, ownerId).downgradeToSolo('missing'), + ).rejects.toThrow('Workspace not found'); + }); + + it('rejects downgradeToSolo when actor is not the primary owner', async () => { + const workspaceId = await createWorkspace(); + await expect( + new WorkspaceModel(serverDB, secondOwnerId).downgradeToSolo(workspaceId), + ).rejects.toThrow('Only the primary owner can downgrade this workspace'); + }); + + it('rejects setGracePeriod when the workspace does not exist', async () => { + await expect( + new WorkspaceModel(serverDB, ownerId).setGracePeriod('missing', 123), + ).rejects.toThrow('Workspace not found'); + }); + }); }); describe('WorkspaceMemberModel', () => { diff --git a/packages/database/src/models/__tests__/workspaceMember.test.ts b/packages/database/src/models/__tests__/workspaceMember.test.ts new file mode 100644 index 0000000000..fa1b49dee9 --- /dev/null +++ b/packages/database/src/models/__tests__/workspaceMember.test.ts @@ -0,0 +1,330 @@ +import { INVITATION_EXPIRY_DAYS } from '@lobechat/const'; +import { and, eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getTestDB } from '../../core/getTestDB'; +import { users, workspaceInvitations, workspaceMembers, workspaces } from '../../schemas'; +import type { LobeChatDatabase } from '../../type'; +import { WorkspaceMemberModel } from '../workspaceMember'; + +const serverDB: LobeChatDatabase = await getTestDB(); + +const inviterId = 'wm-inviter'; +const memberId = 'wm-member'; +const otherUserId = 'wm-other-user'; +const workspaceId = 'wm-workspace'; +const otherWorkspaceId = 'wm-other-workspace'; + +beforeEach(async () => { + await serverDB.delete(users); + await serverDB.insert(users).values([{ id: inviterId }, { id: memberId }, { id: otherUserId }]); + await serverDB.insert(workspaces).values([ + { id: workspaceId, name: 'WS', primaryOwnerId: inviterId, slug: 'ws' }, + { id: otherWorkspaceId, name: 'Other WS', primaryOwnerId: otherUserId, slug: 'other-ws' }, + ]); +}); + +afterEach(async () => { + await serverDB.delete(users); +}); + +describe('WorkspaceMemberModel', () => { + describe('addMember', () => { + it('adds a member with the default role when none is provided', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + const result = await model.addMember({ userId: memberId, workspaceId }); + + expect(result.workspaceId).toBe(workspaceId); + expect(result.userId).toBe(memberId); + expect(result.role).toBe('member'); + expect(result.deletedAt).toBeNull(); + }); + + it('adds a member with an explicit role', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + const result = await model.addMember({ role: 'owner', userId: memberId, workspaceId }); + + expect(result.role).toBe('owner'); + }); + + it('upserts the role and revives a soft-deleted member on conflict', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + await model.addMember({ role: 'member', userId: memberId, workspaceId }); + await model.removeMember(workspaceId, memberId); + + // soft-deleted now; re-adding should revive and update the role + const revived = await model.addMember({ role: 'owner', userId: memberId, workspaceId }); + + expect(revived.role).toBe('owner'); + expect(revived.deletedAt).toBeNull(); + + // composite PK guarantees a single row per (workspace, user) + const rows = await serverDB + .select() + .from(workspaceMembers) + .where(eq(workspaceMembers.workspaceId, workspaceId)); + expect(rows).toHaveLength(1); + }); + + it('falls back to the default role when reviving without an explicit role', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + await model.addMember({ role: 'owner', userId: memberId, workspaceId }); + const revived = await model.addMember({ userId: memberId, workspaceId }); + + expect(revived.role).toBe('member'); + }); + }); + + describe('getMember', () => { + it('returns the active member', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ role: 'viewer', userId: memberId, workspaceId }); + + const found = await model.getMember(workspaceId, memberId); + + expect(found?.userId).toBe(memberId); + expect(found?.role).toBe('viewer'); + }); + + it('returns undefined for a soft-deleted member', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: memberId, workspaceId }); + await model.removeMember(workspaceId, memberId); + + expect(await model.getMember(workspaceId, memberId)).toBeUndefined(); + }); + + it('returns undefined when the member does not exist', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + expect(await model.getMember(workspaceId, 'nobody')).toBeUndefined(); + }); + + it('isolates members across workspaces', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: memberId, workspaceId }); + + expect(await model.getMember(otherWorkspaceId, memberId)).toBeUndefined(); + }); + }); + + describe('listMembers', () => { + it('lists only active members by default', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: inviterId, workspaceId }); + await model.addMember({ userId: memberId, workspaceId }); + await model.removeMember(workspaceId, memberId); + + const rows = await model.listMembers(workspaceId); + + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(inviterId); + }); + + it('includes soft-deleted members when includeDeleted is true', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: inviterId, workspaceId }); + await model.addMember({ userId: memberId, workspaceId }); + await model.removeMember(workspaceId, memberId); + + const rows = await model.listMembers(workspaceId, { includeDeleted: true }); + + expect(rows.map((r) => r.userId).sort()).toEqual([inviterId, memberId].sort()); + }); + + it('does not leak members from other workspaces', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: memberId, workspaceId }); + await model.addMember({ userId: otherUserId, workspaceId: otherWorkspaceId }); + + const rows = await model.listMembers(workspaceId); + + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(memberId); + }); + + it('returns an empty list for a workspace with no members', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + expect(await model.listMembers(workspaceId)).toEqual([]); + }); + }); + + describe('removeMember', () => { + it('soft-deletes an active member', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: memberId, workspaceId }); + + await model.removeMember(workspaceId, memberId); + + const [row] = await serverDB + .select() + .from(workspaceMembers) + .where( + and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, memberId)), + ); + expect(row.deletedAt).not.toBeNull(); + }); + + it('does not touch members of other workspaces', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ userId: otherUserId, workspaceId: otherWorkspaceId }); + + await model.removeMember(workspaceId, otherUserId); + + const [row] = await serverDB + .select() + .from(workspaceMembers) + .where(eq(workspaceMembers.workspaceId, otherWorkspaceId)); + expect(row.deletedAt).toBeNull(); + }); + }); + + describe('updateMemberRole', () => { + it('updates the role of an active member', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ role: 'member', userId: memberId, workspaceId }); + + await model.updateMemberRole(workspaceId, memberId, 'owner'); + + const found = await model.getMember(workspaceId, memberId); + expect(found?.role).toBe('owner'); + }); + + it('does not update the role of a soft-deleted member', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.addMember({ role: 'member', userId: memberId, workspaceId }); + await model.removeMember(workspaceId, memberId); + + await model.updateMemberRole(workspaceId, memberId, 'owner'); + + const [row] = await serverDB + .select() + .from(workspaceMembers) + .where(eq(workspaceMembers.userId, memberId)); + expect(row.role).toBe('member'); + }); + }); + + describe('createInvitation', () => { + it('creates an invitation with the default role and a pending status', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + const result = await model.createInvitation({ email: 'a@b.com', workspaceId }); + + expect(result.workspaceId).toBe(workspaceId); + expect(result.inviterId).toBe(inviterId); + expect(result.email).toBe('a@b.com'); + expect(result.role).toBe('member'); + expect(result.status).toBe('pending'); + expect(result.token).toHaveLength(32); + }); + + it('creates an invitation with an explicit role and an expiry INVITATION_EXPIRY_DAYS out', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + const before = Date.now(); + + const result = await model.createInvitation({ role: 'owner', workspaceId }); + + expect(result.role).toBe('owner'); + expect(result.email).toBeNull(); + const expectedMs = INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000; + const diff = result.expiresAt.getTime() - before; + // allow generous slack for test execution time + expect(diff).toBeGreaterThan(expectedMs - 60_000); + expect(diff).toBeLessThan(expectedMs + 60_000); + }); + + it('generates a unique token per invitation', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + const a = await model.createInvitation({ workspaceId }); + const b = await model.createInvitation({ workspaceId }); + + expect(a.token).not.toBe(b.token); + }); + }); + + describe('findInvitationByToken', () => { + it('finds an invitation by its token', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + const created = await model.createInvitation({ workspaceId }); + + const found = await model.findInvitationByToken(created.token); + + expect(found?.id).toBe(created.id); + }); + + it('returns undefined for an unknown token', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + expect(await model.findInvitationByToken('does-not-exist')).toBeUndefined(); + }); + }); + + describe('listPendingInvitations', () => { + it('lists only pending invitations for the workspace', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + const pending = await model.createInvitation({ workspaceId }); + const accepted = await model.createInvitation({ workspaceId }); + await model.updateInvitationStatus(accepted.id, 'accepted'); + + const rows = await model.listPendingInvitations(workspaceId); + + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(pending.id); + }); + + it('does not include invitations from other workspaces', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + await model.createInvitation({ workspaceId }); + const otherModel = new WorkspaceMemberModel(serverDB, otherUserId); + await otherModel.createInvitation({ workspaceId: otherWorkspaceId }); + + const rows = await model.listPendingInvitations(workspaceId); + + expect(rows).toHaveLength(1); + expect(rows[0].workspaceId).toBe(workspaceId); + }); + + it('returns an empty list when there are no pending invitations', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + + expect(await model.listPendingInvitations(workspaceId)).toEqual([]); + }); + }); + + describe('revokeInvitation', () => { + it('sets the invitation status to revoked', async () => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + const created = await model.createInvitation({ workspaceId }); + + await model.revokeInvitation(created.id); + + const [row] = await serverDB + .select() + .from(workspaceInvitations) + .where(eq(workspaceInvitations.id, created.id)); + expect(row.status).toBe('revoked'); + }); + }); + + describe('updateInvitationStatus', () => { + it.each(['accepted', 'expired', 'revoked'] as const)( + 'updates the invitation status to %s', + async (status) => { + const model = new WorkspaceMemberModel(serverDB, inviterId); + const created = await model.createInvitation({ workspaceId }); + + await model.updateInvitationStatus(created.id, status); + + const found = await model.findInvitationByToken(created.token); + expect(found?.status).toBe(status); + }, + ); + }); +}); diff --git a/packages/database/src/models/_template.ts b/packages/database/src/models/_template.ts index 86974999f7..18f632c7bf 100644 --- a/packages/database/src/models/_template.ts +++ b/packages/database/src/models/_template.ts @@ -1,3 +1,7 @@ +// When you copy this template for a new model, also copy the matching test +// template `./__tests__/_test_template.ts` into `./__tests__/.test.ts`. +// Every model ships with a sibling test — see the `testing` skill +// (.agents/skills/testing/references/db-model-test.md). import { and, desc, eq } from 'drizzle-orm'; import type { NewSessionGroup, SessionGroupItem } from '../schemas'; diff --git a/packages/database/src/models/agentSignal/__tests__/reviewContext.test.ts b/packages/database/src/models/agentSignal/__tests__/reviewContext.test.ts index 182af0a31d..ea9e8ceea2 100644 --- a/packages/database/src/models/agentSignal/__tests__/reviewContext.test.ts +++ b/packages/database/src/models/agentSignal/__tests__/reviewContext.test.ts @@ -10,6 +10,7 @@ import { messagePlugins, messages, topics, + userMemories, users, } from '../../../schemas'; import type { LobeChatDatabase } from '../../../type'; @@ -368,4 +369,228 @@ describe('AgentSignalReviewContextModel', () => { ]); }); }); + + describe('listRelevantMemories', () => { + it('returns recent memory summaries scoped to the user, newest first', async () => { + const otherUserId = 'agent-signal-review-context-other-user'; + + await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]); + await serverDB.insert(userMemories).values([ + { + id: 'agent-signal-review-context-memory-old', + lastAccessedAt: new Date('2026-05-01T00:00:00.000Z'), + summary: 'old memory summary', + updatedAt: new Date('2026-05-01T00:00:00.000Z'), + userId, + }, + { + id: 'agent-signal-review-context-memory-new', + lastAccessedAt: new Date('2026-05-03T00:00:00.000Z'), + summary: 'new memory summary', + updatedAt: new Date('2026-05-03T00:00:00.000Z'), + userId, + }, + { + id: 'agent-signal-review-context-memory-other', + lastAccessedAt: new Date('2026-05-04T00:00:00.000Z'), + summary: 'foreign memory summary', + updatedAt: new Date('2026-05-04T00:00:00.000Z'), + userId: otherUserId, + }, + ]); + + const model = new AgentSignalReviewContextModel(serverDB, userId); + + const result = await model.listRelevantMemories({ limit: 10 }); + + expect(result).toEqual([ + expect.objectContaining({ + content: 'new memory summary', + id: 'agent-signal-review-context-memory-new', + }), + expect.objectContaining({ + content: 'old memory summary', + id: 'agent-signal-review-context-memory-old', + }), + ]); + }); + + it('honors the limit and falls back to title/details when summary is missing', async () => { + await serverDB.insert(users).values({ id: userId }); + await serverDB.insert(userMemories).values([ + { + details: 'detailed body content', + id: 'agent-signal-review-context-memory-details', + lastAccessedAt: new Date('2026-05-05T00:00:00.000Z'), + updatedAt: new Date('2026-05-05T00:00:00.000Z'), + userId, + }, + { + id: 'agent-signal-review-context-memory-title', + lastAccessedAt: new Date('2026-05-04T00:00:00.000Z'), + title: 'memory title only', + updatedAt: new Date('2026-05-04T00:00:00.000Z'), + userId, + }, + ]); + + const model = new AgentSignalReviewContextModel(serverDB, userId); + + const result = await model.listRelevantMemories({ limit: 1 }); + + expect(result).toEqual([ + expect.objectContaining({ + content: 'detailed body content', + id: 'agent-signal-review-context-memory-details', + }), + ]); + }); + + it('returns an empty list when the user has no memories', async () => { + await serverDB.insert(users).values({ id: userId }); + + const model = new AgentSignalReviewContextModel(serverDB, userId); + + await expect(model.listRelevantMemories({ limit: 10 })).resolves.toEqual([]); + }); + }); + + describe('listSelfReflectionTopicActivity', () => { + it('returns scoped failed evidence for a single topic and agent', async () => { + const otherTopicId = 'agent-signal-review-context-other-topic'; + + await serverDB.insert(users).values({ id: userId }); + await serverDB.insert(agents).values([ + { + chatConfig: { selfIteration: { enabled: true } }, + id: agentId, + title: 'Review Context Agent', + userId, + }, + ]); + await serverDB.insert(topics).values([ + { + agentId, + id: topicId, + title: 'Self reflection topic', + userId, + }, + { + agentId, + id: otherTopicId, + title: 'Other topic', + userId, + }, + ]); + await serverDB.insert(messages).values([ + { + agentId, + content: 'assistant failed', + createdAt: new Date('2026-05-03T12:00:00.000Z'), + error: { message: 'model failed mid stream' }, + id: 'agent-signal-review-context-reflection-message-error', + role: 'assistant', + topicId, + userId, + }, + { + agentId, + content: 'tool failed', + createdAt: new Date('2026-05-03T13:00:00.000Z'), + id: 'agent-signal-review-context-reflection-tool-error', + role: 'assistant', + topicId, + userId, + }, + { + agentId, + content: 'other topic failure ignored', + createdAt: new Date('2026-05-03T14:00:00.000Z'), + error: { message: 'ignored other topic error' }, + id: 'agent-signal-review-context-reflection-other-topic', + role: 'assistant', + topicId: otherTopicId, + userId, + }, + ]); + await serverDB.insert(messagePlugins).values({ + apiName: 'search', + error: { message: 'upstream timeout during reflection' }, + id: 'agent-signal-review-context-reflection-tool-error', + identifier: 'web-search', + toolCallId: 'tool-call-reflection', + userId, + }); + + const model = new AgentSignalReviewContextModel(serverDB, userId); + + const result = await model.listSelfReflectionTopicActivity({ + agentId, + topicId, + windowEnd: new Date('2026-05-03T23:59:59.999Z'), + windowStart: new Date('2026-05-03T00:00:00.000Z'), + }); + + expect(result).toEqual([ + expect.objectContaining({ + failedMessages: [ + { + errorSummary: '{"message": "model failed mid stream"}', + messageId: 'agent-signal-review-context-reflection-message-error', + }, + ], + failedToolCalls: [ + { + apiName: 'search', + errorSummary: '{"message": "upstream timeout during reflection"}', + identifier: 'web-search', + messageId: 'agent-signal-review-context-reflection-tool-error', + toolCallId: 'tool-call-reflection', + }, + ], + failedToolCount: 1, + failureCount: 1, + topicId, + }), + ]); + }); + + it('returns an empty list when the topic has no in-window activity', async () => { + await serverDB.insert(users).values({ id: userId }); + await serverDB.insert(agents).values([ + { + chatConfig: { selfIteration: { enabled: true } }, + id: agentId, + title: 'Review Context Agent', + userId, + }, + ]); + await serverDB.insert(topics).values({ + agentId, + id: topicId, + title: 'Self reflection topic', + userId, + }); + await serverDB.insert(messages).values({ + agentId, + content: 'outside window message', + createdAt: new Date('2026-05-10T12:00:00.000Z'), + id: 'agent-signal-review-context-reflection-outside', + role: 'assistant', + topicId, + userId, + }); + + const model = new AgentSignalReviewContextModel(serverDB, userId); + + const result = await model.listSelfReflectionTopicActivity({ + agentId, + topicId, + windowEnd: new Date('2026-05-03T23:59:59.999Z'), + windowStart: new Date('2026-05-03T00:00:00.000Z'), + }); + + expect(result).toEqual([]); + }); + }); }); diff --git a/packages/database/src/models/userMemory/__tests__/query.extended.test.ts b/packages/database/src/models/userMemory/__tests__/query.extended.test.ts new file mode 100644 index 0000000000..43bd58df58 --- /dev/null +++ b/packages/database/src/models/userMemory/__tests__/query.extended.test.ts @@ -0,0 +1,1149 @@ +// @vitest-environment node +import { LayersEnum, RelationshipEnum, UserMemoryContextObjectType } from '@lobechat/types'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { getTestDB } from '../../../core/getTestDB'; +import { + userMemories, + userMemoriesActivities, + userMemoriesContexts, + userMemoriesExperiences, + userMemoriesIdentities, + userMemoriesPreferences, + users, +} from '../../../schemas'; +import type { LobeChatDatabase } from '../../../type'; +import { UserMemoryModel } from '../model'; +import type { LayerBaseMemorySignals } from '../query'; +import { scoreHybridCandidates } from '../query'; + +const userId = 'memory-query-ext-user'; +const otherUserId = 'memory-query-ext-other-user'; + +const serverDB: LobeChatDatabase = await getTestDB(); + +let memoryModel: UserMemoryModel; + +/** + * Direct access to the private query model so we can exercise the semantic + * search SQL in isolation (the public `searchMemory` always also fires the + * BM25 lexical query, which PGlite cannot run because it lacks pg_search). + */ +const getQueryModel = () => + Reflect.get(memoryModel, 'queryModel') as { + searchActivitiesSemantic: ( + embedding: number[], + limit: number, + params: Record, + ) => Promise>; + searchContextsSemantic: ( + embedding: number[], + limit: number, + params: Record, + ) => Promise>; + searchExperiencesSemantic: ( + embedding: number[], + limit: number, + params: Record, + ) => Promise>; + searchIdentitiesSemantic: ( + embedding: number[], + limit: number, + params: Record, + ) => Promise>; + searchPreferencesSemantic: ( + embedding: number[], + limit: number, + params: Record, + ) => Promise>; + }; + +const vec = (seed: number) => Array.from({ length: 1024 }, (_, index) => (index === 0 ? seed : 0)); + +beforeEach(async () => { + await serverDB.delete(userMemoriesActivities); + await serverDB.delete(userMemoriesContexts); + await serverDB.delete(userMemoriesExperiences); + await serverDB.delete(userMemoriesIdentities); + await serverDB.delete(userMemoriesPreferences); + await serverDB.delete(userMemories); + await serverDB.delete(users); + + await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]); + memoryModel = new UserMemoryModel(serverDB, userId); +}); + +const createActivityPair = async (opts: { + capturedAt?: Date; + memoryCategory?: string; + memoryTags?: string[]; + narrative?: string; + narrativeVector?: number[]; + ownerId?: string; + status?: string; + tags?: string[]; + title?: string; + type?: string; +}) => { + const owner = opts.ownerId ?? userId; + const [memory] = await serverDB + .insert(userMemories) + .values({ + capturedAt: opts.capturedAt, + details: 'activity details', + lastAccessedAt: new Date(), + memoryCategory: opts.memoryCategory, + memoryLayer: 'activity', + memoryType: 'activity', + summary: 'activity summary', + tags: opts.memoryTags ?? opts.tags, + title: opts.title ?? 'Activity memory', + userId: owner, + }) + .returning(); + + const [activity] = await serverDB + .insert(userMemoriesActivities) + .values({ + capturedAt: opts.capturedAt, + narrative: opts.narrative ?? 'did a thing', + narrativeVector: opts.narrativeVector, + status: opts.status ?? 'completed', + tags: opts.tags, + type: opts.type ?? 'task', + userId: owner, + userMemoryId: memory.id, + } as typeof userMemoriesActivities.$inferInsert) + .returning(); + + return { activity, memory }; +}; + +const createContextPair = async (opts: { + currentStatus?: string; + description?: string; + descriptionVector?: number[]; + memoryCategory?: string; + memoryTags?: string[]; + tags?: string[]; + title?: string; + type?: string; +}) => { + const [memory] = await serverDB + .insert(userMemories) + .values({ + details: 'context details', + lastAccessedAt: new Date(), + memoryCategory: opts.memoryCategory, + memoryLayer: 'context', + memoryType: 'context', + summary: 'context summary', + tags: opts.memoryTags ?? opts.tags, + title: opts.title ?? 'Context memory', + userId, + }) + .returning(); + + const [context] = await serverDB + .insert(userMemoriesContexts) + .values({ + associatedObjects: [{ name: 'Linear', type: UserMemoryContextObjectType.Application }], + currentStatus: opts.currentStatus, + description: opts.description ?? 'A context description', + descriptionVector: opts.descriptionVector, + tags: opts.tags, + title: opts.title ?? 'Atlas context', + type: opts.type ?? 'project', + userId, + userMemoryIds: [memory.id], + } as typeof userMemoriesContexts.$inferInsert) + .returning(); + + return { context, memory }; +}; + +const createExperiencePair = async (opts: { + capturedAt?: Date; + memoryCategory?: string; + memoryTags?: string[]; + situation?: string; + situationVector?: number[]; + tags?: string[]; + title?: string; + type?: string; +}) => { + const [memory] = await serverDB + .insert(userMemories) + .values({ + capturedAt: opts.capturedAt, + details: 'experience details', + lastAccessedAt: new Date(), + memoryCategory: opts.memoryCategory, + memoryLayer: 'experience', + memoryType: 'experience', + summary: 'experience summary', + tags: opts.memoryTags ?? opts.tags, + title: opts.title ?? 'Experience memory', + userId, + }) + .returning(); + + const [experience] = await serverDB + .insert(userMemoriesExperiences) + .values({ + capturedAt: opts.capturedAt, + situation: opts.situation ?? 'A tricky migration situation', + situationVector: opts.situationVector, + tags: opts.tags, + type: opts.type ?? 'lesson', + userId, + userMemoryId: memory.id, + } as typeof userMemoriesExperiences.$inferInsert) + .returning(); + + return { experience, memory }; +}; + +const createPreferencePair = async (opts: { + conclusionDirectives?: string; + conclusionDirectivesVector?: number[]; + memoryCategory?: string; + memoryTags?: string[]; + tags?: string[]; + title?: string; + type?: string; +}) => { + const [memory] = await serverDB + .insert(userMemories) + .values({ + details: 'preference details', + lastAccessedAt: new Date(), + memoryCategory: opts.memoryCategory, + memoryLayer: 'preference', + memoryType: 'preference', + summary: 'preference summary', + tags: opts.memoryTags ?? opts.tags, + title: opts.title ?? 'Preference memory', + userId, + }) + .returning(); + + const [preference] = await serverDB + .insert(userMemoriesPreferences) + .values({ + conclusionDirectives: opts.conclusionDirectives ?? 'Prefer typed APIs', + conclusionDirectivesVector: opts.conclusionDirectivesVector, + suggestions: 'Add more integration tests', + tags: opts.tags, + type: opts.type ?? 'coding-style', + userId, + userMemoryId: memory.id, + } as typeof userMemoriesPreferences.$inferInsert) + .returning(); + + return { memory, preference }; +}; + +const createIdentityPair = async (opts: { + description?: string; + descriptionVector?: number[]; + episodicDate?: Date; + memoryCategory?: string; + memoryTags?: string[]; + relationship?: RelationshipEnum; + role?: string; + tags?: string[]; + title?: string; + type?: 'demographic' | 'personal' | 'professional'; +}) => { + const [memory] = await serverDB + .insert(userMemories) + .values({ + details: 'identity details', + lastAccessedAt: new Date(), + memoryCategory: opts.memoryCategory, + memoryLayer: 'identity', + memoryType: 'identity', + summary: 'identity summary', + tags: opts.memoryTags ?? opts.tags, + title: opts.title ?? 'Identity memory', + userId, + }) + .returning(); + + const [identity] = await serverDB + .insert(userMemoriesIdentities) + .values({ + description: opts.description ?? 'Identity description', + descriptionVector: opts.descriptionVector, + episodicDate: opts.episodicDate, + relationship: opts.relationship ?? RelationshipEnum.Self, + role: opts.role ?? 'Engineer', + tags: opts.tags, + type: opts.type ?? 'personal', + userId, + userMemoryId: memory.id, + } as typeof userMemoriesIdentities.$inferInsert) + .returning(); + + return { identity, memory }; +}; + +describe('queryTaxonomyOptions (extended)', () => { + it('aggregates base-memory categories ordered by count then value', async () => { + await createActivityPair({ memoryCategory: 'project', title: 'a' }); + await createActivityPair({ memoryCategory: 'project', title: 'b' }); + await createContextPair({ memoryCategory: 'personal' }); + + const result = await memoryModel.queryTaxonomyOptions({ include: ['categories'], limit: 10 }); + + expect(result.categories).toEqual([ + { count: 2, layers: undefined, value: 'project' }, + { count: 1, layers: undefined, value: 'personal' }, + ]); + expect(result.hasMore.categories).toBe(false); + }); + + it('filters categories by layer and reports hasMore when limit is hit', async () => { + await createActivityPair({ memoryCategory: 'project' }); + await createContextPair({ memoryCategory: 'personal' }); + + const result = await memoryModel.queryTaxonomyOptions({ + include: ['categories'], + layers: [LayersEnum.Activity], + limit: 1, + }); + + expect(result.categories).toEqual([{ count: 1, layers: undefined, value: 'project' }]); + expect(result.hasMore.categories).toBe(true); + }); + + it('aggregates layer types merged across layers', async () => { + await createActivityPair({ type: 'task' }); + await createContextPair({ type: 'project' }); + await createExperiencePair({ type: 'lesson' }); + await createPreferencePair({ type: 'coding-style' }); + await createIdentityPair({ type: 'personal' }); + + const result = await memoryModel.queryTaxonomyOptions({ include: ['types'], limit: 10 }); + + const values = result.types.map((row) => row.value).sort(); + expect(values).toEqual(['coding-style', 'lesson', 'personal', 'project', 'task']); + const taskRow = result.types.find((row) => row.value === 'task'); + expect(taskRow?.layers).toContain(LayersEnum.Activity); + }); + + it('restricts layer types when layers filter is provided', async () => { + await createActivityPair({ type: 'task' }); + await createContextPair({ type: 'project' }); + + const result = await memoryModel.queryTaxonomyOptions({ + include: ['types'], + layers: [LayersEnum.Context], + limit: 10, + }); + + expect(result.types.map((row) => row.value)).toEqual(['project']); + }); + + it('aggregates statuses from activity status and context currentStatus', async () => { + await createActivityPair({ status: 'completed' }); + await createActivityPair({ status: 'completed' }); + await createContextPair({ currentStatus: 'active' }); + + const result = await memoryModel.queryTaxonomyOptions({ include: ['statuses'], limit: 10 }); + + expect(result.statuses).toContainEqual({ + count: 2, + layers: [LayersEnum.Activity], + value: 'completed', + }); + expect(result.statuses).toContainEqual({ + count: 1, + layers: [LayersEnum.Context], + value: 'active', + }); + }); + + it('aggregates identity relationships and roles', async () => { + await createIdentityPair({ relationship: RelationshipEnum.Friend, role: 'Sponsor' }); + await createIdentityPair({ relationship: RelationshipEnum.Friend, role: 'Observer' }); + + const relationships = await memoryModel.queryTaxonomyOptions({ + include: ['relationships'], + limit: 10, + }); + expect(relationships.relationships).toEqual([ + { count: 2, layers: [LayersEnum.Identity], value: RelationshipEnum.Friend }, + ]); + + const roles = await memoryModel.queryTaxonomyOptions({ include: ['roles'], limit: 10 }); + expect(roles.roles.map((row) => row.value).sort()).toEqual(['Observer', 'Sponsor']); + }); + + it('applies the q filter to identity relationship/role aggregation', async () => { + await createIdentityPair({ role: 'Backend Engineer' }); + await createIdentityPair({ role: 'Product Manager' }); + + const result = await memoryModel.queryTaxonomyOptions({ + include: ['roles'], + limit: 10, + q: 'engineer', + }); + + expect(result.roles.map((row) => row.value)).toEqual(['Backend Engineer']); + }); + + it('applies a timeRange filter to base-memory tag aggregation', async () => { + await createActivityPair({ + capturedAt: new Date('2026-03-20T10:00:00.000Z'), + memoryTags: ['recent'], + tags: ['recent'], + }); + await createActivityPair({ + capturedAt: new Date('2026-01-01T10:00:00.000Z'), + memoryTags: ['old'], + tags: ['old'], + }); + + const result = await memoryModel.queryTaxonomyOptions({ + include: ['tags'], + limit: 10, + timeRange: { + end: new Date('2026-03-21T00:00:00.000Z'), + field: 'capturedAt', + start: new Date('2026-03-19T00:00:00.000Z'), + }, + }); + + expect(result.tags.map((row) => row.value)).toEqual(['recent']); + }); + + it('resolves to no rows when the timeRange field is unsupported by the source table', async () => { + await createActivityPair({ memoryCategory: 'project' }); + + const result = await memoryModel.queryTaxonomyOptions({ + include: ['categories'], + limit: 10, + timeRange: { + // episodicDate is not part of the base-memory time field map + field: 'episodicDate', + start: new Date('2026-03-19T00:00:00.000Z'), + }, + }); + + expect(result.categories).toEqual([]); + }); + + it('returns the full default taxonomy result when include is omitted', async () => { + await createActivityPair({ memoryCategory: 'project', tags: ['atlas'], type: 'task' }); + await createIdentityPair({ relationship: RelationshipEnum.Friend, role: 'Sponsor' }); + + const result = await memoryModel.queryTaxonomyOptions(); + + expect(result.categories.length).toBeGreaterThan(0); + expect(result.tags.length).toBeGreaterThan(0); + expect(result.types.length).toBeGreaterThan(0); + expect(result.relationships.length).toBeGreaterThan(0); + expect(result.roles.length).toBeGreaterThan(0); + }); + + it('returns empty buckets when include is an empty list', async () => { + await createActivityPair({ memoryCategory: 'project' }); + + const result = await memoryModel.queryTaxonomyOptions({ include: [], limit: 10 }); + + expect(result.categories).toEqual([]); + expect(result.tags).toEqual([]); + expect(result.types).toEqual([]); + expect(result.statuses).toEqual([]); + }); + + it('only aggregates the current user memories (ownership isolation)', async () => { + await createActivityPair({ memoryCategory: 'mine' }); + await createActivityPair({ memoryCategory: 'theirs', ownerId: otherUserId }); + + const result = await memoryModel.queryTaxonomyOptions({ include: ['categories'], limit: 10 }); + + expect(result.categories.map((row) => row.value)).toEqual(['mine']); + }); +}); + +describe('semantic search (non-BM25 vector paths)', () => { + it('orders activities by cosine similarity to the query embedding', async () => { + const { activity: near } = await createActivityPair({ + narrative: 'near match', + narrativeVector: vec(1), + title: 'near', + }); + const { activity: far } = await createActivityPair({ + narrative: 'far match', + narrativeVector: vec(-1), + title: 'far', + }); + + const rows = await getQueryModel().searchActivitiesSemantic(vec(1), 5, {}); + + expect(rows.map((row) => row.id)).toEqual([near.id, far.id]); + }); + + it('applies category/status/type/timeRange/tag filters to activity semantic search', async () => { + const { activity: kept } = await createActivityPair({ + capturedAt: new Date('2026-03-20T10:00:00.000Z'), + memoryCategory: 'project', + memoryTags: ['atlas'], + narrativeVector: vec(1), + status: 'completed', + tags: ['atlas'], + type: 'task', + }); + await createActivityPair({ + capturedAt: new Date('2026-01-01T10:00:00.000Z'), + memoryCategory: 'personal', + narrativeVector: vec(1), + status: 'pending', + type: 'note', + }); + + const rows = await getQueryModel().searchActivitiesSemantic(vec(1), 5, { + categories: ['project'], + labels: ['atlas'], + status: ['completed'], + timeRange: { + end: new Date('2026-03-21T00:00:00.000Z'), + field: 'capturedAt', + start: new Date('2026-03-19T00:00:00.000Z'), + }, + types: ['task'], + }); + + expect(rows.map((row) => row.id)).toEqual([kept.id]); + }); + + it('dedupes and orders contexts by cosine similarity', async () => { + const { context: near } = await createContextPair({ + description: 'near context', + descriptionVector: vec(1), + title: 'near ctx', + }); + const { context: far } = await createContextPair({ + description: 'far context', + descriptionVector: vec(-1), + title: 'far ctx', + }); + + const rows = await getQueryModel().searchContextsSemantic(vec(1), 5, {}); + + expect(rows.map((row) => row.id)).toEqual([near.id, far.id]); + }); + + it('applies category and status filters to context semantic search', async () => { + const { context: kept } = await createContextPair({ + currentStatus: 'active', + description: 'kept context', + descriptionVector: vec(1), + memoryCategory: 'project', + }); + await createContextPair({ + currentStatus: 'archived', + description: 'dropped context', + descriptionVector: vec(1), + memoryCategory: 'personal', + }); + + const rows = await getQueryModel().searchContextsSemantic(vec(1), 5, { + categories: ['project'], + status: ['active'], + }); + + expect(rows.map((row) => row.id)).toEqual([kept.id]); + }); + + it('orders experiences by cosine similarity to the query embedding', async () => { + const { experience: near } = await createExperiencePair({ + situation: 'near experience', + situationVector: vec(1), + title: 'near exp', + }); + const { experience: far } = await createExperiencePair({ + situation: 'far experience', + situationVector: vec(-1), + title: 'far exp', + }); + + const rows = await getQueryModel().searchExperiencesSemantic(vec(1), 5, {}); + + expect(rows.map((row) => row.id)).toEqual([near.id, far.id]); + }); + + it('applies category filter to experience semantic search', async () => { + const { experience: kept } = await createExperiencePair({ + memoryCategory: 'project', + situation: 'kept experience', + situationVector: vec(1), + }); + await createExperiencePair({ + memoryCategory: 'personal', + situation: 'dropped experience', + situationVector: vec(1), + }); + + const rows = await getQueryModel().searchExperiencesSemantic(vec(1), 5, { + categories: ['project'], + }); + + expect(rows.map((row) => row.id)).toEqual([kept.id]); + }); + + it('orders preferences by cosine similarity and respects type/category filter', async () => { + const { preference: kept } = await createPreferencePair({ + conclusionDirectives: 'typed apis', + conclusionDirectivesVector: vec(1), + memoryCategory: 'project', + type: 'coding-style', + }); + await createPreferencePair({ + conclusionDirectives: 'concise notes', + conclusionDirectivesVector: vec(1), + memoryCategory: 'personal', + type: 'communication-style', + }); + + const rows = await getQueryModel().searchPreferencesSemantic(vec(1), 5, { + categories: ['project'], + types: ['coding-style'], + }); + + expect(rows.map((row) => row.id)).toEqual([kept.id]); + }); + + it('orders identities by cosine similarity and respects relationship/category filter', async () => { + const { identity: kept } = await createIdentityPair({ + description: 'sponsor identity', + descriptionVector: vec(1), + memoryCategory: 'project', + relationship: RelationshipEnum.Friend, + }); + await createIdentityPair({ + description: 'self identity', + descriptionVector: vec(1), + memoryCategory: 'personal', + relationship: RelationshipEnum.Self, + }); + + const rows = await getQueryModel().searchIdentitiesSemantic(vec(1), 5, { + categories: ['project'], + relationships: [RelationshipEnum.Friend], + }); + + expect(rows.map((row) => row.id)).toEqual([kept.id]); + }); +}); + +describe('lexical filter-only search (no BM25 query)', () => { + it('filters activities by timeRange without running BM25', async () => { + const { activity: kept } = await createActivityPair({ + capturedAt: new Date('2026-03-20T10:00:00.000Z'), + tags: ['atlas'], + title: 'recent activity', + }); + await createActivityPair({ + capturedAt: new Date('2026-01-01T10:00:00.000Z'), + tags: ['atlas'], + title: 'old activity', + }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Activity], + timeRange: { + end: new Date('2026-03-21T00:00:00.000Z'), + field: 'capturedAt', + start: new Date('2026-03-19T00:00:00.000Z'), + }, + topK: { activities: 5, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities.map((item) => item.id)).toEqual([kept.id]); + }); + + it('filters experiences by type and tags without running BM25', async () => { + const { experience: kept } = await createExperiencePair({ + memoryTags: ['migration'], + tags: ['migration'], + title: 'migration lesson', + type: 'lesson', + }); + await createExperiencePair({ + memoryTags: ['other'], + tags: ['other'], + title: 'other note', + type: 'note', + }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Experience], + tags: ['migration'], + topK: { activities: 0, contexts: 0, experiences: 5, identities: 0, preferences: 0 }, + types: ['lesson'], + }); + + expect(result.experiences.map((item) => item.id)).toEqual([kept.id]); + }); + + it('filters contexts by category without running BM25', async () => { + const { context: kept } = await createContextPair({ + memoryCategory: 'project', + memoryTags: ['atlas'], + tags: ['atlas'], + title: 'project context', + }); + await createContextPair({ + memoryCategory: 'personal', + memoryTags: ['atlas'], + tags: ['atlas'], + title: 'personal context', + }); + + const result = await memoryModel.searchMemory({ + categories: ['project'], + layers: [LayersEnum.Context], + tags: ['atlas'], + topK: { activities: 0, contexts: 5, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.contexts.map((item) => item.id)).toEqual([kept.id]); + }); + + it('filters preferences by category without running BM25', async () => { + const { preference: kept } = await createPreferencePair({ + memoryCategory: 'project', + memoryTags: ['typescript'], + tags: ['typescript'], + }); + await createPreferencePair({ + memoryCategory: 'personal', + memoryTags: ['typescript'], + tags: ['typescript'], + }); + + const result = await memoryModel.searchMemory({ + categories: ['project'], + layers: [LayersEnum.Preference], + tags: ['typescript'], + topK: { activities: 0, contexts: 0, experiences: 0, identities: 0, preferences: 5 }, + }); + + expect(result.preferences.map((item) => item.id)).toEqual([kept.id]); + }); + + it('filters identities by category and relationship without running BM25', async () => { + const { identity: kept } = await createIdentityPair({ + memoryCategory: 'project', + memoryTags: ['atlas'], + relationship: RelationshipEnum.Friend, + tags: ['atlas'], + }); + await createIdentityPair({ + memoryCategory: 'personal', + memoryTags: ['atlas'], + relationship: RelationshipEnum.Self, + tags: ['atlas'], + }); + + const result = await memoryModel.searchMemory({ + categories: ['project'], + layers: [LayersEnum.Identity], + relationships: [RelationshipEnum.Friend], + tags: ['atlas'], + topK: { activities: 0, contexts: 0, experiences: 0, identities: 5, preferences: 0 }, + }); + + expect(result.identities.map((item) => item.id)).toEqual([kept.id]); + }); + + it('filters activities by category without running BM25', async () => { + const { activity: kept } = await createActivityPair({ + memoryCategory: 'project', + memoryTags: ['atlas'], + tags: ['atlas'], + title: 'project activity', + }); + await createActivityPair({ + memoryCategory: 'personal', + memoryTags: ['atlas'], + tags: ['atlas'], + title: 'personal activity', + }); + + const result = await memoryModel.searchMemory({ + categories: ['project'], + layers: [LayersEnum.Activity], + tags: ['atlas'], + topK: { activities: 5, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities.map((item) => item.id)).toEqual([kept.id]); + }); + + it('filters experiences by category without running BM25', async () => { + const { experience: kept } = await createExperiencePair({ + memoryCategory: 'project', + memoryTags: ['migration'], + tags: ['migration'], + }); + await createExperiencePair({ + memoryCategory: 'personal', + memoryTags: ['migration'], + tags: ['migration'], + }); + + const result = await memoryModel.searchMemory({ + categories: ['project'], + layers: [LayersEnum.Experience], + tags: ['migration'], + topK: { activities: 0, contexts: 0, experiences: 5, identities: 0, preferences: 0 }, + }); + + expect(result.experiences.map((item) => item.id)).toEqual([kept.id]); + }); + + it('supports a start-only timeRange filter', async () => { + const { activity: kept } = await createActivityPair({ + capturedAt: new Date('2026-03-20T10:00:00.000Z'), + tags: ['atlas'], + title: 'after start', + }); + await createActivityPair({ + capturedAt: new Date('2026-01-01T10:00:00.000Z'), + tags: ['atlas'], + title: 'before start', + }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Activity], + tags: ['atlas'], + timeRange: { field: 'capturedAt', start: new Date('2026-03-01T00:00:00.000Z') }, + topK: { activities: 5, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities.map((item) => item.id)).toEqual([kept.id]); + }); + + it('supports an end-only timeRange filter', async () => { + await createActivityPair({ + capturedAt: new Date('2026-03-20T10:00:00.000Z'), + tags: ['atlas'], + title: 'after end', + }); + const { activity: kept } = await createActivityPair({ + capturedAt: new Date('2026-01-01T10:00:00.000Z'), + tags: ['atlas'], + title: 'before end', + }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Activity], + tags: ['atlas'], + timeRange: { end: new Date('2026-02-01T00:00:00.000Z'), field: 'capturedAt' }, + topK: { activities: 5, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities.map((item) => item.id)).toEqual([kept.id]); + }); + + it('runs the lexical path purely from a types filter (hasSearchFilters via types)', async () => { + const { activity: kept } = await createActivityPair({ title: 'typed', type: 'task' }); + await createActivityPair({ title: 'untyped', type: 'note' }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Activity], + topK: { activities: 5, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + types: ['task'], + }); + + expect(result.activities.map((item) => item.id)).toEqual([kept.id]); + }); + + it('returns empty layers when topK is zero across the board', async () => { + await createActivityPair({ tags: ['atlas'] }); + + const result = await memoryModel.searchMemory({ + tags: ['atlas'], + topK: { activities: 0, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities).toEqual([]); + expect(result.contexts).toEqual([]); + expect(result.experiences).toEqual([]); + expect(result.identities).toEqual([]); + expect(result.preferences).toEqual([]); + }); + + it('skips both lexical and semantic retrieval when there are no queries and no filters', async () => { + await createActivityPair({ tags: ['atlas'] }); + await createContextPair({ tags: ['atlas'] }); + await createExperiencePair({ tags: ['atlas'] }); + await createIdentityPair({ tags: ['atlas'] }); + await createPreferencePair({ tags: ['atlas'] }); + + // every layer is requested with topK > 0 but no queries/filters, so both the + // lexical and semantic candidate lists collapse to [] in every hybrid method. + const result = await memoryModel.searchMemory({ + topK: { activities: 5, contexts: 5, experiences: 5, identities: 5, preferences: 5 }, + }); + + expect(result.activities).toEqual([]); + expect(result.contexts).toEqual([]); + expect(result.experiences).toEqual([]); + expect(result.identities).toEqual([]); + expect(result.preferences).toEqual([]); + }); + + it('treats a timeRange with neither start nor end as no filter', async () => { + await createActivityPair({ tags: ['atlas'], title: 'a' }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Activity], + tags: ['atlas'], + // empty bounds -> buildTimeRangeCondition returns undefined, so only the tag filter applies + timeRange: { field: 'capturedAt' }, + topK: { activities: 5, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities).toHaveLength(1); + }); + + it('reports hasMore in layer meta when results exceed the per-layer limit', async () => { + await createActivityPair({ tags: ['atlas'], title: 'one' }); + await createActivityPair({ tags: ['atlas'], title: 'two' }); + + const result = await memoryModel.searchMemory({ + layers: [LayersEnum.Activity], + tags: ['atlas'], + topK: { activities: 1, contexts: 0, experiences: 0, identities: 0, preferences: 0 }, + }); + + expect(result.activities).toHaveLength(1); + expect(result.meta.layers.activities.hasMore).toBe(true); + expect(result.meta.layers.activities.total).toBe(2); + }); +}); + +describe('scoreHybridCandidates (edge cases)', () => { + it('returns an empty list when there are no items', () => { + const result = scoreHybridCandidates({ + baseSignals: new Map(), + items: [], + lexicalLists: [], + queries: ['anything'], + queryParams: { queries: ['anything'] }, + semanticLists: [], + }); + + expect(result).toEqual([]); + }); + + it('handles candidates without base signals and with non-string field values', () => { + const item = { + // numeric + object + array fields exercise extractSearchableTerms branches + id: 'lonely', + narrative: 'project atlas review', + scoreImpact: 42, + metadata: { nested: 'atlas detail' }, + tags: ['atlas'], + }; + + const result = scoreHybridCandidates({ + baseSignals: new Map(), + items: [item], + lexicalLists: [[item]], + queries: ['atlas'], + queryParams: { queries: ['atlas'] }, + semanticLists: [], + }); + + expect(result).toHaveLength(1); + expect(result[0].item.id).toBe('lonely'); + // no base signals and no other candidates -> no cluster boost + expect(result[0].score.clusterBoost).toBe(0); + expect(result[0].score.fuzzy).toBeGreaterThan(0); + }); + + it('uses the default temporal window when start and end collapse to the same instant', () => { + const sameInstant = new Date('2026-03-20T10:00:00.000Z'); + const item = { + capturedAt: new Date('2026-03-25T10:00:00.000Z'), + id: 'temporal', + narrative: 'review', + tags: [], + }; + + const result = scoreHybridCandidates({ + baseSignals: new Map(), + items: [item], + lexicalLists: [[item]], + queries: ['review'], + queryParams: { + queries: ['review'], + timeRange: { end: sameInstant, field: 'capturedAt', start: sameInstant }, + }, + semanticLists: [], + }); + + // outside the window but scored against the default window -> bounded (0, 1) + expect(result[0].score.temporal).toBeGreaterThan(0); + expect(result[0].score.temporal).toBeLessThan(1); + }); + + it('handles a single-sided timeRange (start only) when scoring temporal distance', () => { + const item = { + capturedAt: new Date('2026-03-25T10:00:00.000Z'), + id: 'single-sided', + narrative: 'review', + tags: [], + }; + + const result = scoreHybridCandidates({ + baseSignals: new Map(), + items: [item], + lexicalLists: [[item]], + queries: ['review'], + queryParams: { + queries: ['review'], + timeRange: { field: 'capturedAt', start: new Date('2026-03-20T10:00:00.000Z') }, + }, + semanticLists: [], + }); + + expect(result[0].score.temporal).toBeGreaterThanOrEqual(0); + expect(result[0].score.temporal).toBeLessThanOrEqual(1); + }); + + it('does not boost candidates that share no temporal proximity with seeds', () => { + const seedTime = new Date('2026-03-20T10:00:00.000Z').getTime(); + const baseSignals = new Map([ + [ + 'seed', + { categories: ['project'], memoryIds: ['m-seed'], tags: ['atlas'], times: [seedTime] }, + ], + // far-future candidate has times, but distance is enormous + [ + 'far', + { + categories: ['project'], + memoryIds: ['m-far'], + tags: ['atlas'], + times: [seedTime + 1000 * 60 * 60 * 24 * 365], + }, + ], + ]); + const seed = { + capturedAt: new Date(seedTime), + id: 'seed', + narrative: 'atlas', + tags: ['atlas'], + }; + const far = { + capturedAt: new Date(seedTime + 1000 * 60 * 60 * 24 * 365), + id: 'far', + narrative: 'atlas', + tags: ['atlas'], + }; + + const result = scoreHybridCandidates({ + baseSignals, + items: [seed, far], + lexicalLists: [[seed, far]], + queries: ['atlas'], + queryParams: { queries: ['atlas'] }, + semanticLists: [], + }); + + const farScore = result.find((entry) => entry.item.id === 'far')?.score; + // temporal proximity collapses to ~0 across a one-year gap + expect(farScore?.clusterBoost).toBeCloseTo(0, 5); + }); + + it('treats candidates and seeds with no time signals as infinitely distant', () => { + const baseSignals = new Map([ + ['seed', { categories: ['project'], memoryIds: ['m-seed'], tags: ['atlas'], times: [] }], + ['other', { categories: ['project'], memoryIds: ['m-other'], tags: ['atlas'], times: [] }], + ]); + // items carry no date fields at all, so candidateTimes resolves to empty + const seed = { id: 'seed', narrative: 'atlas', tags: ['atlas'] }; + const other = { id: 'other', narrative: 'atlas', tags: ['atlas'] }; + + const result = scoreHybridCandidates({ + baseSignals, + items: [seed, other], + lexicalLists: [[seed, other]], + queries: ['atlas'], + queryParams: { queries: ['atlas'] }, + semanticLists: [], + }); + + // no temporal proximity -> short-term association is zero -> no cluster boost, + // but tag/category affinity still register + const otherScore = result.find((entry) => entry.item.id === 'other')?.score; + expect(otherScore?.clusterBoost).toBe(0); + expect(otherScore?.tagAffinity).toBeGreaterThan(0); + }); + + it('coerces string and numeric date fields when collecting candidate times', () => { + const start = new Date('2026-03-19T00:00:00.000Z'); + const end = new Date('2026-03-21T00:00:00.000Z'); + const item = { + // string + numeric epoch date fields exercise the coerceDate string/number branch + capturedAt: '2026-03-20T10:00:00.000Z', + createdAt: new Date('2026-03-20T10:00:00.000Z').getTime(), + id: 'coerced', + narrative: 'atlas review', + tags: ['atlas'], + }; + + const result = scoreHybridCandidates({ + baseSignals: new Map(), + items: [item], + lexicalLists: [[item]], + queries: ['atlas'], + queryParams: { + queries: ['atlas'], + timeRange: { end, field: 'capturedAt', start }, + }, + semanticLists: [], + }); + + // the coerced timestamps fall inside the time window -> full temporal score + expect(result[0].score.temporal).toBe(1); + }); + + it('ignores non-date-like values when coercing candidate times', () => { + const item = { + // an object value is neither Date, string, nor number -> coerceDate returns null + capturedAt: { not: 'a date' } as unknown as Date, + id: 'bad-date', + narrative: 'atlas review', + tags: ['atlas'], + }; + + const result = scoreHybridCandidates({ + baseSignals: new Map(), + items: [item], + lexicalLists: [[item]], + queries: ['atlas'], + queryParams: { + queries: ['atlas'], + timeRange: { + end: new Date('2026-03-21T00:00:00.000Z'), + field: 'capturedAt', + start: new Date('2026-03-19T00:00:00.000Z'), + }, + }, + semanticLists: [], + }); + + // no usable timestamps -> temporal score is zero, but the candidate still ranks + expect(result).toHaveLength(1); + expect(result[0].score.temporal).toBe(0); + }); +}); diff --git a/packages/database/src/repositories/dataImporter/__tests__/index.test.ts b/packages/database/src/repositories/dataImporter/__tests__/index.test.ts index da2f17c408..3f61cad682 100644 --- a/packages/database/src/repositories/dataImporter/__tests__/index.test.ts +++ b/packages/database/src/repositories/dataImporter/__tests__/index.test.ts @@ -1,6 +1,6 @@ -import type { ImportPgDataStructure } from '@lobechat/types'; +import type { ImporterEntryData, ImportErrorResult, ImportPgDataStructure } from '@lobechat/types'; import { eq } from 'drizzle-orm'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTestDB } from '../../../core/getTestDB'; import * as Schema from '../../../schemas'; @@ -425,4 +425,230 @@ describe('DataImporter', () => { }); }); }); + + describe('importData (deprecated entry wrapper)', () => { + it('should delegate to the deprecated importer and wrap the result', async () => { + const data: ImporterEntryData = { + version: 7, + sessionGroups: [], + sessions: [], + topics: [], + messages: [], + } as unknown as ImporterEntryData; + + const result = await importer.importData(data); + + expect(result.success).toBe(true); + expect(result.results).toBeDefined(); + }); + }); + + describe('importPgData error handling (outer catch)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return success=false with parsed unique-constraint error details', async () => { + const uniqueError: any = new Error('duplicate key value violates unique constraint'); + uniqueError.code = '23505'; + uniqueError.detail = 'Key (slug)=(my-agent) already exists.'; + + vi.spyOn(clientDB, 'transaction').mockRejectedValueOnce(uniqueError); + + const result = await importer.importPgData(agentsData as ImportPgDataStructure); + + expect(result.success).toBe(false); + expect((result as ImportErrorResult).error).toMatchObject({ + message: 'duplicate key value violates unique constraint', + details: { + constraintType: 'unique', + field: 'slug', + value: 'my-agent', + }, + }); + }); + + it('should fall back to raw detail when error is not a 23505 unique violation', async () => { + const genericError: any = new Error('some db failure'); + genericError.detail = 'raw detail message'; + + vi.spyOn(clientDB, 'transaction').mockRejectedValueOnce(genericError); + + const result = await importer.importPgData(agentsData as ImportPgDataStructure); + + expect(result.success).toBe(false); + expect((result as ImportErrorResult).error).toMatchObject({ + message: 'some db failure', + details: 'raw detail message', + }); + }); + + it('should fall back to "Unknown error details" when 23505 detail is unparseable', async () => { + const weirdUniqueError: any = new Error('weird unique violation'); + weirdUniqueError.code = '23505'; + weirdUniqueError.detail = 'no parseable key here'; + + vi.spyOn(clientDB, 'transaction').mockRejectedValueOnce(weirdUniqueError); + + const result = await importer.importPgData(agentsData as ImportPgDataStructure); + + expect(result.success).toBe(false); + expect((result as ImportErrorResult).error?.details).toBe('no parseable key here'); + }); + }); + + describe('batch insert error handling (in-batch duplicate)', () => { + it('should record errors when two composite-key rows collide on the same primary key', async () => { + // userInstalledPlugins uses composite PK [userId, identifier]; two rows with the + // same identifier are both "new" (not yet in DB), pass the conflict pre-check, + // and then violate the PK during the batch insert -> batch catch path. + const data: ImportPgDataStructure = { + data: { + userInstalledPlugins: [ + { + identifier: 'dup-plugin', + type: 'plugin', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }, + { + identifier: 'dup-plugin', + type: 'plugin', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }, + ], + }, + mode: 'pglite', + schemaHash: 'test', + } as any; + + const result = await importer.importPgData(data); + + // The transaction itself still succeeds; the batch error is swallowed and counted. + expect(result.success).toBe(true); + expect(result.results.userInstalledPlugins?.errors).toBe(2); + }); + }); + + describe('userInstalledPlugins (composite key + merge strategy)', () => { + it('should insert then merge on identifier conflict and bump updated count', async () => { + const firstData: ImportPgDataStructure = { + data: { + userInstalledPlugins: [ + { + identifier: 'merge-plugin', + type: 'plugin', + source: 'first', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }, + ], + }, + mode: 'pglite', + schemaHash: 'test', + } as any; + + const firstResult = await importer.importPgData(firstData); + expect(firstResult.success).toBe(true); + expect(firstResult.results.userInstalledPlugins).toMatchObject({ added: 1, errors: 0 }); + + // Re-import the same identifier (twice in one payload) with a fresh importer. + // First row triggers merge (exists), establishing the conflict; the second row in + // the same call also hits merge after the first update committed -> updated++ branch. + const importer2 = new DataImporterRepos(clientDB, userId); + const secondData: ImportPgDataStructure = { + data: { + userInstalledPlugins: [ + { + identifier: 'merge-plugin', + type: 'plugin', + source: 'second', + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z', + }, + { + identifier: 'merge-plugin', + type: 'plugin', + source: 'third', + createdAt: '2025-03-01T00:00:00Z', + updatedAt: '2025-03-01T00:00:00Z', + }, + ], + }, + mode: 'pglite', + schemaHash: 'test', + } as any; + + const secondResult = await importer2.importPgData(secondData); + expect(secondResult.success).toBe(true); + expect(secondResult.results.userInstalledPlugins?.updated).toBe(2); + expect(secondResult.results.userInstalledPlugins?.added).toBe(0); + + // Only one row should exist (merged), with the latest merged source value. + const rows = await clientDB.query.userInstalledPlugins.findMany({ + where: eq(Schema.userInstalledPlugins.userId, userId), + }); + expect(rows).toHaveLength(1); + expect(rows[0].source).toBe('third'); + }); + }); + + describe('aiModels (non-composite skip strategy on unique constraint)', () => { + it('should skip a new model whose providerId already exists and map its id', async () => { + // aiModels config: conflictStrategy 'skip', uniqueConstraints ['id','providerId']. + // Import a first model with providerId 'prov-x'. + const firstData: ImportPgDataStructure = { + data: { + aiModels: [ + { + id: 'model-a', + providerId: 'prov-x', + type: 'chat', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }, + ], + }, + mode: 'pglite', + schemaHash: 'test', + } as any; + + const firstResult = await importer.importPgData(firstData); + expect(firstResult.success).toBe(true); + expect(firstResult.results.aiModels).toMatchObject({ added: 1, errors: 0 }); + + // A second, different model (new id) but same providerId -> providerId unique + // constraint conflict -> skip branch (no providers imported here so providerId is + // not remapped and keeps colliding). + const importer2 = new DataImporterRepos(clientDB, userId); + const secondData: ImportPgDataStructure = { + data: { + aiModels: [ + { + id: 'model-b', + providerId: 'prov-x', + type: 'chat', + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z', + }, + ], + }, + mode: 'pglite', + schemaHash: 'test', + } as any; + + const secondResult = await importer2.importPgData(secondData); + expect(secondResult.success).toBe(true); + expect(secondResult.results.aiModels?.added).toBe(0); + expect(secondResult.results.aiModels?.skips).toBeGreaterThanOrEqual(1); + + const models = await clientDB.query.aiModels.findMany({ + where: eq(Schema.aiModels.userId, userId), + }); + // model-b was skipped, only model-a persisted. + expect(models).toHaveLength(1); + expect(models[0].id).toBe('model-a'); + }); + }); }); diff --git a/packages/database/src/repositories/home/__tests__/index.test.ts b/packages/database/src/repositories/home/__tests__/index.test.ts index 6d434ec9ce..6cbb1a55a1 100644 --- a/packages/database/src/repositories/home/__tests__/index.test.ts +++ b/packages/database/src/repositories/home/__tests__/index.test.ts @@ -621,4 +621,230 @@ describe('HomeRepository', () => { }); }); }); + + describe('getSidebarAgentList - heterogeneous type', () => { + it('should expose heterogeneousType from agencyConfig.heterogeneousProvider.type', async () => { + await clientDB.insert(Schema.agents).values({ + id: 'hetero-agent', + userId, + title: 'Hetero Agent', + pinned: false, + virtual: false, + agencyConfig: { heterogeneousProvider: { type: 'claude-code' } }, + }); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.ungrouped).toHaveLength(1); + expect(result.ungrouped[0].id).toBe('hetero-agent'); + expect(result.ungrouped[0].heterogeneousType).toBe('claude-code'); + }); + + it('should leave heterogeneousType unset when agencyConfig has no heterogeneousProvider', async () => { + await clientDB.insert(Schema.agents).values({ + id: 'no-hetero-agent', + userId, + title: 'No Hetero Agent', + pinned: false, + virtual: false, + agencyConfig: { executionTarget: 'none' }, + }); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.ungrouped).toHaveLength(1); + // heterogeneousType resolves to null and is stripped by cleanObject + expect(result.ungrouped[0].heterogeneousType).toBeUndefined(); + }); + }); + + describe('getSidebarAgentList - session group resolution', () => { + it('should use agents.sessionGroupId to place agent into a folder', async () => { + // Folder + agent that references the folder directly via agents.sessionGroupId + await clientDB.transaction(async (tx) => { + await tx.insert(Schema.sessionGroups).values({ + id: 'folder-direct', + name: 'Direct Folder', + sort: 0, + userId, + }); + await tx.insert(Schema.agents).values({ + id: 'agent-direct-group', + userId, + title: 'Direct Group Agent', + pinned: false, + virtual: false, + sessionGroupId: 'folder-direct', + }); + }); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0].id).toBe('folder-direct'); + expect(result.groups[0].items).toHaveLength(1); + expect(result.groups[0].items[0].id).toBe('agent-direct-group'); + expect(result.ungrouped).toHaveLength(0); + }); + + it('should prioritize agents.sessionGroupId over sessions.groupId', async () => { + // agents.sessionGroupId points to folder A; sessions.groupId points to folder B. + // The agent must land in folder A. + await clientDB.transaction(async (tx) => { + await tx.insert(Schema.sessionGroups).values([ + { id: 'folder-a', name: 'Folder A', sort: 0, userId }, + { id: 'folder-b', name: 'Folder B', sort: 1, userId }, + ]); + await tx.insert(Schema.agents).values({ + id: 'agent-priority-group', + userId, + title: 'Priority Group Agent', + pinned: false, + virtual: false, + sessionGroupId: 'folder-a', + }); + await tx.insert(Schema.sessions).values({ + id: 'session-priority-group', + slug: 'session-priority-group', + userId, + groupId: 'folder-b', + }); + await tx.insert(Schema.agentsToSessions).values({ + agentId: 'agent-priority-group', + sessionId: 'session-priority-group', + userId, + }); + }); + + const result = await homeRepo.getSidebarAgentList(); + + const folderA = result.groups.find((g) => g.id === 'folder-a'); + const folderB = result.groups.find((g) => g.id === 'folder-b'); + expect(folderA?.items.map((i) => i.id)).toContain('agent-priority-group'); + expect(folderB?.items).toHaveLength(0); + }); + + it('should fall back to sessions.groupId when agents.sessionGroupId is null', async () => { + await clientDB.transaction(async (tx) => { + await tx.insert(Schema.sessionGroups).values({ + id: 'folder-fallback', + name: 'Fallback Folder', + sort: 0, + userId, + }); + await tx.insert(Schema.agents).values({ + id: 'agent-fallback-group', + userId, + title: 'Fallback Group Agent', + pinned: false, + virtual: false, + // sessionGroupId intentionally not set + }); + await tx.insert(Schema.sessions).values({ + id: 'session-fallback-group', + slug: 'session-fallback-group', + userId, + groupId: 'folder-fallback', + }); + await tx.insert(Schema.agentsToSessions).values({ + agentId: 'agent-fallback-group', + sessionId: 'session-fallback-group', + userId, + }); + }); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0].id).toBe('folder-fallback'); + expect(result.groups[0].items.map((i) => i.id)).toContain('agent-fallback-group'); + }); + }); + + describe('getSidebarAgentList - chat group member avatars', () => { + it('should fall back to member avatars when chat group has no custom avatar', async () => { + await clientDB.transaction(async (tx) => { + await tx.insert(Schema.chatGroups).values({ + id: 'cg-members', + userId, + title: 'Members Group', + pinned: false, + }); + await tx.insert(Schema.agents).values([ + { + id: 'cg-member-1', + userId, + title: 'Member One', + avatar: '🤖', + backgroundColor: '#101010', + virtual: true, + }, + { + id: 'cg-member-2', + userId, + title: 'Member Two', + avatar: '👤', + // no backgroundColor -> exercises `?? undefined` branch + virtual: true, + }, + ]); + await tx.insert(Schema.chatGroupsAgents).values([ + { agentId: 'cg-member-1', chatGroupId: 'cg-members', order: 0, userId }, + { agentId: 'cg-member-2', chatGroupId: 'cg-members', order: 1, userId }, + ]); + }); + + const result = await homeRepo.getSidebarAgentList(); + + const group = result.ungrouped.find((i) => i.id === 'cg-members'); + expect(group).toBeDefined(); + expect(group!.type).toBe('group'); + expect(Array.isArray(group!.avatar)).toBe(true); + const avatars = group!.avatar as Array<{ avatar: string; background?: string }>; + expect(avatars).toHaveLength(2); + expect(avatars[0]).toEqual({ avatar: '🤖', background: '#101010' }); + // member without a backgroundColor should omit `background` + expect(avatars[1]).toEqual({ avatar: '👤', background: undefined }); + }); + + it('should skip members without an avatar when building member avatar list', async () => { + await clientDB.transaction(async (tx) => { + await tx.insert(Schema.chatGroups).values({ + id: 'cg-noavatar', + userId, + title: 'No Avatar Members Group', + pinned: false, + }); + await tx.insert(Schema.agents).values([ + { + id: 'cg-has-avatar', + userId, + title: 'Has Avatar', + avatar: '🎉', + virtual: true, + }, + { + id: 'cg-null-avatar', + userId, + title: 'No Avatar', + // avatar omitted -> should be skipped + virtual: true, + }, + ]); + await tx.insert(Schema.chatGroupsAgents).values([ + { agentId: 'cg-has-avatar', chatGroupId: 'cg-noavatar', order: 0, userId }, + { agentId: 'cg-null-avatar', chatGroupId: 'cg-noavatar', order: 1, userId }, + ]); + }); + + const result = await homeRepo.getSidebarAgentList(); + + const group = result.ungrouped.find((i) => i.id === 'cg-noavatar'); + expect(group).toBeDefined(); + const avatars = group!.avatar as Array<{ avatar: string; background?: string }>; + // only the member with an avatar is included + expect(avatars).toHaveLength(1); + expect(avatars[0].avatar).toBe('🎉'); + }); + }); }); diff --git a/packages/database/src/repositories/home/index.test.ts b/packages/database/src/repositories/home/index.test.ts index 230c40ab7f..51357f2200 100644 --- a/packages/database/src/repositories/home/index.test.ts +++ b/packages/database/src/repositories/home/index.test.ts @@ -718,7 +718,9 @@ describe('HomeRepository', () => { }); }); - describe('searchAgents', () => { + // BM25 search requires pg_search extension (ParadeDB), not available in PGlite + const isServerDB = process.env.TEST_SERVER_DB === '1'; + describe.skipIf(!isServerDB)('searchAgents', () => { it('should return empty array for empty keyword', async () => { const result = await homeRepo.searchAgents(''); expect(result).toEqual([]);