test(database): raise model/repository coverage to 95%+ and document DB test conventions (#15611)

*  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 <noreply@anthropic.com>

*  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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-10 01:42:08 +08:00
committed by GitHub
parent 991c2f79e8
commit 3ce3b5388f
23 changed files with 5351 additions and 104 deletions
+8
View File
@@ -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__/<name>.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`
+1
View File
@@ -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__/<name>.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)
+8 -2
View File
@@ -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__/<name>.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 |
@@ -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__/<name>.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<MyModel>) => {
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<MyModel>) => {
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<MyModel>) =>
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).
@@ -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', () => {
@@ -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();
});
});
});
@@ -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>): NewUserConnector => ({
identifier: 'linear',
name: 'Linear',
sourceType: 'builtin',
status: 'connected',
userId,
...overrides,
});
const insertConnector = async (overrides: Partial<NewUserConnector>): Promise<string> => {
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>): 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']);
});
});
});
@@ -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<string, unknown>).duplicatedFrom).toBe('copy-file-1');
expect((copied?.metadata as Record<string, unknown>).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');
});
});
});
@@ -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();
});
});
});
@@ -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<string, unknown> = {}) => ({
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');
});
});
});
@@ -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<string> => {
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);
});
});
});
@@ -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([]);
@@ -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<string, any>;
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<string, any>).duplicatedFrom).toBe(root.id);
// Config preserved
expect((clonedRoot!.config as Record<string, any>).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();
});
});
});
@@ -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);
@@ -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']);
});
});
@@ -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', () => {
@@ -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);
},
);
});
});
@@ -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__/<name>.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';
@@ -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([]);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -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');
});
});
});
@@ -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('🎉');
});
});
});
@@ -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([]);