feat(documents): folder filter on list + per-doc move endpoint
listDocuments accepts folderId (string | null | undefined) and
includeDescendants. folderId=null returns only docs at root;
includeDescendants=true expands the subtree via collectDescendantIds
(in-memory walk over the cached tree -- folder trees are small).
PATCH /api/v1/documents/[id]/folder moves a single document under
documents.manage_folders, with audit-log metadata { type: 'folder_move' }.
Bumping updatedAt is correct for per-doc moves because reps deliberately
acted on that document -- different semantics from the bulk soft-rescue
in Task 4.
createDocument accepts an optional folderId for the upcoming UI's
"create in current folder" affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
tests/integration/documents-list-folder-filter.test.ts
Normal file
129
tests/integration/documents-list-folder-filter.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Task 7 — listDocuments folder filtering (TDD).
|
||||
*
|
||||
* Exercises the three folderId modes: null (root only), a string (direct
|
||||
* children), and a string with includeDescendants=true (subtree). Mirrors the
|
||||
* pattern in document-folders-crud.test.ts: makePort returns a Port row, and
|
||||
* TEST_USER_ID is resolved once via beforeAll from any seeded user.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, beforeAll } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documents, documentFolders } from '@/lib/db/schema/documents';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { createFolder } from '@/lib/services/document-folders.service';
|
||||
import { listDocuments } from '@/lib/services/documents.service';
|
||||
import { makePort } from '../helpers/factories';
|
||||
|
||||
describe('documents.listDocuments folder filtering', () => {
|
||||
let portId: string;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||
if (!u) throw new Error('No user available; run pnpm db:seed first');
|
||||
testUserId = u.id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const port = await makePort();
|
||||
portId = port.id;
|
||||
await db.delete(documents).where(eq(documents.portId, portId));
|
||||
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||||
});
|
||||
|
||||
it('filters by folderId (direct children only by default)', async () => {
|
||||
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
||||
const sub = await createFolder(portId, testUserId, { name: 'Sub', parentId: root.id });
|
||||
await db.insert(documents).values([
|
||||
{
|
||||
portId,
|
||||
documentType: 'other',
|
||||
title: 'In Root',
|
||||
createdBy: testUserId,
|
||||
folderId: root.id,
|
||||
},
|
||||
{ portId, documentType: 'other', title: 'In Sub', createdBy: testUserId, folderId: sub.id },
|
||||
{
|
||||
portId,
|
||||
documentType: 'other',
|
||||
title: 'At Root (no folder)',
|
||||
createdBy: testUserId,
|
||||
folderId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await listDocuments(portId, {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
order: 'desc',
|
||||
includeArchived: false,
|
||||
includeDescendants: false,
|
||||
folderId: root.id,
|
||||
});
|
||||
const titles = (res.data as Array<{ title: string }>).map((d) => d.title);
|
||||
expect(titles).toContain('In Root');
|
||||
expect(titles).not.toContain('In Sub');
|
||||
expect(titles).not.toContain('At Root (no folder)');
|
||||
});
|
||||
|
||||
it('includeDescendants=true pulls in children of children', async () => {
|
||||
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
||||
const sub = await createFolder(portId, testUserId, { name: 'Sub', parentId: root.id });
|
||||
await db.insert(documents).values([
|
||||
{
|
||||
portId,
|
||||
documentType: 'other',
|
||||
title: 'In Root',
|
||||
createdBy: testUserId,
|
||||
folderId: root.id,
|
||||
},
|
||||
{ portId, documentType: 'other', title: 'In Sub', createdBy: testUserId, folderId: sub.id },
|
||||
]);
|
||||
|
||||
const res = await listDocuments(portId, {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
order: 'desc',
|
||||
includeArchived: false,
|
||||
includeDescendants: true,
|
||||
folderId: root.id,
|
||||
});
|
||||
const titles = (res.data as Array<{ title: string }>).map((d) => d.title);
|
||||
expect(titles).toContain('In Root');
|
||||
expect(titles).toContain('In Sub');
|
||||
});
|
||||
|
||||
it('folderId=null returns only docs at root', async () => {
|
||||
const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null });
|
||||
await db.insert(documents).values([
|
||||
{
|
||||
portId,
|
||||
documentType: 'other',
|
||||
title: 'In Root',
|
||||
createdBy: testUserId,
|
||||
folderId: root.id,
|
||||
},
|
||||
{
|
||||
portId,
|
||||
documentType: 'other',
|
||||
title: 'At Root',
|
||||
createdBy: testUserId,
|
||||
folderId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await listDocuments(portId, {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
order: 'desc',
|
||||
includeArchived: false,
|
||||
includeDescendants: false,
|
||||
folderId: null,
|
||||
});
|
||||
const titles = (res.data as Array<{ title: string }>).map((d) => d.title);
|
||||
expect(titles).toEqual(['At Root']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user