mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✅ 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:
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user