mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task status is the main signal on every task surface: sidebar rows, the task header, the status menu, lists, the inbox, pills and chat cards. > - One task state rendered different status icons in different views. A blocked task that waits on an active sub-task showed the blue "in queue" glyph in the task header. The status menu, the chat chip and the sidebar rows showed the red "Blocked" glyph for the same task. > - The cause: each view passed its own subset of the presentation fields to `StatusIcon`. Many views dropped `blockerAttention` and some dropped `externalConversationState`. The status menu rendered its current row from the bare status. The summaries API did not return `blockerAttention`, so the recent-task sidebar could not know the state. > - This pull request puts the state-to-glyph/label mapping in one resolver and makes every task view pass all of its fields. > - The benefit is that the same state uses the same icon and label everywhere. ## Linked Issues or Issue Description No public issue. Found in a hands-on onboarding test on a staging cloud workspace. **What happened?** The sidebar and the task pane/status menu showed different status icons for the same state. On the test task (blocked, waiting on an active sub-task), the task header and the status button showed the blue "in queue" glyph. The selected "Blocked" row in the status menu showed the red blocked glyph. The coordinator sidebar "Recents" and project rows also showed the red blocked glyph, and the chat "Subtask created" chip showed a red "Blocked" chip. **Expected behavior** The same underlying state uses the same icon and label everywhere. **Steps to reproduce** 1. Create a task with a sub-task. Block the parent on the sub-task, and let the sub-task's agent run (the parent is "blocked, covered"). 2. Open the parent task. The header shows the blue "in queue" glyph. 3. Open the status menu. The current "Blocked" row shows the red glyph. 4. With the coordinator sidebar on, look at the task in Recents or in its expanded project. It shows the red glyph. **Paperclip version or commit** `master` at03eb49053f. ## What Changed - Add `ui/src/lib/task-status-presentation.ts`. `resolveTaskStatusPresentation()` is the one mapping from `status`, `displayStatus`, `externalConversationState` and `blockerAttention` to glyph, label and accessible copy. `taskStatusIconProps()` picks all four fields from a task record. - `StatusIcon` and `IssueStatusBadge` use the resolver. The status menu draws the current row with the trigger's presentation (in queue, idle, monitoring). - `IssueStatusBadge` now accepts `blockerAttention`. `taskStatusVar.in_queue` maps to the existing in-progress chip hue, the same as the existing `--status-task-icon-in_queue` icon token. - Task call sites spread `taskStatusIconProps(task)`: coordinator sidebar recents and project rows, classic recent tasks, lists, rows, columns, inbox, blocked inbox, my issues, dashboard, properties, relation pills, search, sibling navigation, chat chips, case summaries, status cards and user profile. Step/check statuses in protocol cards stay unchanged because they are not task statuses. - `GET /api/companies/:companyId/issues/summaries` returns `blockerAttention` for blocked rows (at most five IDs, through the existing `listBlockerAttention` service). `IssueSummary` gets the optional field. - Recent-task snapshots store and refresh `blockerAttention`. Sidebar project rows request the `rows` projection, which includes blocker state (the `index` projection skips it). - Storybook: the Foundations/Status Language story gets a "Same state, every view" section. ## Verification Red on `master` (tests written first, then run against the unchanged code): ``` cd ui && npx vitest run src/components/StatusIcon.picker.test.tsx src/components/CoordinatorSidebar.test.tsx src/lib/recent-tasks.test.ts × keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07) × marks the current row with the same glyph as the trigger for a covered-blocked task × marks the current row with the same glyph as the trigger for a waiting review and a monitored task × shows a covered-blocked recent task with the same queued glyph and label as the task pane × shows project tasks with their full status presentation FAIL StatusIcon.picker.test.tsx > ... covered-blocked task AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)' FAIL StatusIcon.picker.test.tsx > ... waiting review and a monitored task AssertionError: expected 'var(--status-task-icon-in_review)' to be 'var(--status-task-icon-backlog)' FAIL CoordinatorSidebar.test.tsx > ... covered-blocked recent task ... AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)' FAIL CoordinatorSidebar.test.tsx > ... project tasks with their full status presentation AssertionError: expected "vi.fn()" to be called with arguments: [ 'company', ObjectContaining{…} ] FAIL recent-tasks.test.ts > ... keeps the covered-blocker state ... AssertionError: expected undefined to deeply equal { state: 'covered', …(7) } Tests 5 failed | 32 passed (37) cd server && npx vitest run src/__tests__/issue-summaries-routes.test.ts × projects the covered-blocker state so every task view picks the same queued glyph (BUG-07) AssertionError: expected undefined to match object { state: 'covered', …(2) } Tests 1 failed | 14 passed (15) ``` Green with this change (same commands): ``` Test Files 3 passed (3) Tests 37 passed (37) Test Files 1 passed (1) # server summaries route Tests 15 passed (15) ``` Neighbouring and broader checks: - `StatusIcon`, `StatusBadge`, `StatusGlyph`, picker, sidebar and recent-task suites: 73/73 pass. - Full UI Vitest run: 11106 pass, 1 skipped. Three `IssuesList.test.tsx` tests timed out at 5 s under full-suite load. The same file passes 73/73 when run alone. - `tsc --noEmit` for `ui` and `server`: no errors in changed files. Remaining errors are pre-existing (unbuilt `@paperclipai/paperclip-runner`, generated Storybook catalog). - `pnpm check:token-gates`: all gates clean. Screenshots. The staging task before the fix: the status button is blue, the current "Blocked" row in the menu is red. | Staging task (before) | Staging status menu (before) | | --- | --- | |  |  | Storybook "Same state, every view" (Foundations/Status Language). Before uses the `master` `StatusIcon`/`StatusBadge`; after uses this branch. | Before (master) | After (this PR) | | --- | --- | |  |  | Before: the row glyph is blue, but the chip and the current menu row are red. After: the row glyph, the chip and the current menu row are all the blue "in queue" glyph. Review follow-up (commits1d3a1a0a9e,d5c798ec53): blocker state on summaries, search and case responses is glyph-only (state, reason, counts). Blocker titles and IDs are never returned; a sampled identifier stays only when the actor can read that blocker. Producer-level tests failed before the follow-up and pass after it (\`task-created-items.test.tsx\`, \`CoordinatorTaskCommitment.test.tsx\`, \`company-search-service.test.ts\`, \`cases-routes.test.ts\`, private-blocker case in \`issue-summaries-routes.test.ts\`). Before merge, the branch was test-merged locally with current \`master\`: typecheck clean, 1095 UI and 80 server tests in the affected suites pass. ## Risks - Low risk. Presentation only. Status mutations still use the canonical `status`. - The summaries endpoint now runs the blocker-attention query for blocked rows. The endpoint accepts at most five IDs and runs the query only when a row is blocked. - Sidebar project rows use the `rows` projection instead of `index`. This adds the blocker and review attention reads for at most 12 rows, and only when a person expands a project. - `IssueStatusBadge` shows the blue "in queue" chip for covered-blocked tasks where callers pass `blockerAttention`. The label stays "Blocked" and the full reason is in the tooltip. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), through Claude Code with tool use (shell, file edits, browser). Default context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1030 lines
43 KiB
TypeScript
1030 lines
43 KiB
TypeScript
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
documents,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueAccessGrants,
|
|
issueRelations,
|
|
issueLabels,
|
|
issueWorkProducts,
|
|
issues,
|
|
labels,
|
|
projectAccessMembers,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
COMPANY_SEARCH_BRANCH_FETCH_LIMIT,
|
|
companySearchBranchFetchLimit,
|
|
companySearchService,
|
|
} from "../services/company-search.js";
|
|
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
|
|
import { issueService } from "../services/issues.js";
|
|
|
|
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_COMPANY_SEARCH_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres company search tests on this host: ${("reason" in embeddedPostgresSupport ? embeddedPostgresSupport.reason : "unsupported environment")}`,
|
|
);
|
|
}
|
|
|
|
describe("company search query validation", () => {
|
|
it("truncates long text queries but rejects invalid filters, sort, and pagination", () => {
|
|
const parsed = companySearchQuerySchema.parse({
|
|
q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50),
|
|
limit: "50",
|
|
offset: "200",
|
|
scope: "all",
|
|
status: "todo,blocked",
|
|
priority: ["critical", "low"],
|
|
sort: "priority",
|
|
updatedWithin: "7d",
|
|
});
|
|
|
|
expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH);
|
|
expect(parsed.status).toEqual(["todo", "blocked"]);
|
|
expect(parsed.priority).toEqual(["critical", "low"]);
|
|
expect(parsed.sort).toBe("priority");
|
|
expect(parsed.updatedWithin).toBe("7d");
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", limit: "500" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", offset: "9000" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", scope: "not-a-scope" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", status: "not-a-status" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", priority: "urgent" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", sort: "oldest" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", updatedWithin: "forever" })).toThrow();
|
|
expect(() => companySearchQuerySchema.parse({ q: "needle", projectId: "not-a-uuid" })).toThrow();
|
|
});
|
|
|
|
it("includes offset in the internal per-branch fetch window", () => {
|
|
const lowOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "0" });
|
|
const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "200" });
|
|
|
|
expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51);
|
|
expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT);
|
|
});
|
|
});
|
|
|
|
describeEmbeddedPostgres("companySearchService", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof companySearchService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (!externalDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-");
|
|
db = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
|
|
// External suites can reuse this dedicated database sequentially. Match
|
|
// the fresh embedded fixture before this suite creates its own rows.
|
|
if (externalDatabaseUrl) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
|
|
svc = companySearchService(db);
|
|
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documents);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueLabels);
|
|
await db.delete(issueAccessGrants);
|
|
await db.delete(issues);
|
|
await db.delete(labels);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createCompany(name = "Paperclip") {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name,
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
return companyId;
|
|
}
|
|
|
|
async function createIssue(companyId: string, values: Partial<typeof issues.$inferInsert> = {}) {
|
|
const id = values.id ?? randomUUID();
|
|
await db.insert(issues).values({
|
|
id,
|
|
companyId,
|
|
title: values.title ?? "Search target",
|
|
description: values.description ?? null,
|
|
status: values.status ?? "todo",
|
|
priority: values.priority ?? "medium",
|
|
identifier: values.identifier ?? null,
|
|
hiddenAt: values.hiddenAt ?? null,
|
|
...values,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
async function createAgent(companyId: string, values: Partial<typeof agents.$inferInsert> = {}) {
|
|
const id = values.id ?? randomUUID();
|
|
await db.insert(agents).values({
|
|
id,
|
|
companyId,
|
|
name: values.name ?? "Search agent",
|
|
role: values.role ?? "engineer",
|
|
title: values.title ?? null,
|
|
capabilities: values.capabilities ?? null,
|
|
...values,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
async function createProject(companyId: string, values: Partial<typeof projects.$inferInsert> = {}) {
|
|
const id = values.id ?? randomUUID();
|
|
await db.insert(projects).values({
|
|
id,
|
|
companyId,
|
|
name: values.name ?? "Search project",
|
|
description: values.description ?? null,
|
|
...values,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
async function createLabel(companyId: string, values: Partial<typeof labels.$inferInsert> = {}) {
|
|
const id = values.id ?? randomUUID();
|
|
await db.insert(labels).values({
|
|
id,
|
|
companyId,
|
|
name: values.name ?? "Search label",
|
|
color: values.color ?? "blue",
|
|
...values,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
it("returns glyph-only blocker state for blocked results without blocker details (BUG-07)", async () => {
|
|
const companyId = await createCompany();
|
|
const parent = await createIssue(companyId, { title: "Launch parent", status: "blocked" });
|
|
const blocker = await createIssue(companyId, { title: "Hidden blocker detail", status: "todo", identifier: "SRCH-2" });
|
|
const open = await createIssue(companyId, { title: "Launch open", status: "todo" });
|
|
await db.insert(issueRelations).values({ companyId, issueId: blocker, relatedIssueId: parent, type: "blocks" });
|
|
try {
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "launch", scope: "issues" }));
|
|
const blocked = result.results.find((row) => row.id === parent)?.issue;
|
|
expect(blocked?.blockerAttention).toMatchObject({ state: "needs_attention", unresolvedBlockerCount: 1 });
|
|
expect(Object.keys(blocked!.blockerAttention!).sort()).toEqual([
|
|
"attentionBlockerCount", "coveredBlockerCount", "reason", "sampleBlockerIdentifier",
|
|
"sampleStalledBlockerIdentifier", "stalledBlockerCount", "state", "unresolvedBlockerCount",
|
|
]);
|
|
expect(blocked?.blockerAttention?.sampleBlockerIdentifier).toBeNull();
|
|
expect(JSON.stringify(result)).not.toContain("Hidden blocker detail");
|
|
expect(JSON.stringify(result)).not.toContain(blocker);
|
|
expect(result.results.find((row) => row.id === open)?.issue).not.toHaveProperty("blockerAttention");
|
|
} finally {
|
|
await db.delete(issueRelations);
|
|
}
|
|
});
|
|
|
|
it("paginates issue-result-only searches before unrelated entity competition", async () => {
|
|
const companyId = await createCompany();
|
|
const now = Date.now();
|
|
const older = await createIssue(companyId, { title: "Launch older", updatedAt: new Date(now - 2_000) });
|
|
await createIssue(companyId, { title: "Launch newer", updatedAt: new Date(now - 1_000) });
|
|
await createAgent(companyId, { name: "Launch agent", updatedAt: new Date(now) });
|
|
await createProject(companyId, { name: "Launch project", updatedAt: new Date(now + 1_000) });
|
|
const holder = await createIssue(companyId, { title: "Artifact holder" });
|
|
await db.insert(issueWorkProducts).values({ companyId, issueId: holder, type: "artifact", provider: "paperclip",
|
|
title: "Launch artifact", status: "ready", updatedAt: new Date(now + 2_000) });
|
|
const query = companySearchQuerySchema.parse({ q: "launch", scope: "all", sort: "updated", limit: 1, offset: 1 });
|
|
const mixed = await svc.search(companyId, { ...query, limit: 10, offset: 0 });
|
|
expect(new Set(mixed.results.map(row => row.type))).toEqual(new Set(["issue", "agent", "project", "artifact"]));
|
|
const result = await svc.search(companyId, query, { issueResultsOnly: true });
|
|
expect(result.results.map(row => row.id)).toEqual([older]);
|
|
expect(result.countsByType).toMatchObject({ issue: 2, agent: 0, project: 0, artifact: 0 });
|
|
expect(result.filterOptionCounts.status.todo).toBe(2);
|
|
expect(result.hasMore).toBe(false);
|
|
for (const scope of ["agents", "projects", "artifacts"] as const) {
|
|
let membership: Date | null | undefined;
|
|
const excluded = await svc.search(companyId, { ...query, scope }, {
|
|
issueResultsOnly: true, onNextMembershipChange: at => { membership = at; },
|
|
});
|
|
expect(excluded.results).toEqual([]);
|
|
expect(Object.values(excluded.countsByType).every(count => count === 0)).toBe(true);
|
|
expect(excluded.filterOptionCounts.status).toEqual({});
|
|
expect(excluded.hasMore).toBe(false);
|
|
expect(membership).toBeNull();
|
|
}
|
|
let deadline: Date | null = null;
|
|
await svc.search(companyId, { ...query, updatedWithin: "1h" }, {
|
|
issueResultsOnly: true, onNextMembershipChange: at => { deadline = at; },
|
|
});
|
|
expect(deadline).toEqual(new Date(now - 2_000 + 3_600_001));
|
|
});
|
|
|
|
it.each(["all", "comments", "documents"] as const)("preserves issue-result-only %s text matching and read boundaries", async scope => {
|
|
const previous = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
|
try {
|
|
const companyId = await createCompany();
|
|
const comment = await createIssue(companyId, { title: "Comment context" });
|
|
const document = await createIssue(companyId, { title: "Document context" });
|
|
const privateId = randomUUID();
|
|
await createIssue(companyId, { id: privateId, title: "Private context", visibility: "private",
|
|
privacyRootIssueId: privateId, responsibleUserId: "private-owner" });
|
|
const foreign = await createIssue(await createCompany(), { title: "Foreign context" });
|
|
const hidden = await createIssue(companyId, { title: "Hidden context", hiddenAt: new Date() });
|
|
for (const issueId of [comment, privateId, hidden]) await db.insert(issueComments).values({ companyId, issueId, body: "Needle context" });
|
|
const foreignCompany = await db.select({ companyId: issues.companyId }).from(issues).where(sql`${issues.id} = ${foreign}`);
|
|
await db.insert(issueComments).values({ companyId: foreignCompany[0]!.companyId, issueId: foreign, body: "Needle context" });
|
|
for (const issueId of [document, privateId, hidden]) {
|
|
const id = randomUUID();
|
|
await db.insert(documents).values({ id, companyId, title: "Context", latestBody: "Needle context", format: "markdown" });
|
|
await db.insert(issueDocuments).values({ companyId, issueId, documentId: id, key: "brief" });
|
|
}
|
|
const condition = await issueReadSqlCondition(db, { type: "board", userId: "reader", source: "session" });
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", scope }), {
|
|
issueResultsOnly: true, issueReadCondition: condition,
|
|
});
|
|
expect(new Set(result.results.map(row => row.id))).toEqual(new Set(scope === "all" ? [comment, document] : [scope === "comments" ? comment : document]));
|
|
expect(result.results.every(row => row.type === "issue")).toBe(true);
|
|
expect(result.countsByType).toMatchObject({ agent: 0, project: 0, artifact: 0 });
|
|
} finally {
|
|
if (previous === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previous;
|
|
}
|
|
});
|
|
|
|
it("keeps issue-result-only loosen counts inside the selected result kinds", async () => {
|
|
const companyId = await createCompany();
|
|
await createIssue(companyId, { title: "Launch done", status: "done" });
|
|
await createAgent(companyId, { name: "Launch agent" });
|
|
await createProject(companyId, { name: "Launch project" });
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "launch", status: "todo" }), { issueResultsOnly: true });
|
|
expect(result.results).toEqual([]);
|
|
expect(result.zeroResults).toMatchObject({ unfilteredTotal: 1,
|
|
loosenSuggestions: [{ filter: "status", resultCount: 1, additionalCount: 1 }] });
|
|
expect(result.filterOptionCounts.status.done).toBe(1);
|
|
});
|
|
|
|
it("keeps exact entity names ahead of speculative task typos and rejects empty quotes", async () => {
|
|
const companyId = await createCompany();
|
|
const agentId = await createAgent(companyId, { name: "Mibile" });
|
|
const projectId = await createProject(companyId, { name: "Mibile" });
|
|
const taskId = await createIssue(companyId, { title: "Mobile navigation" });
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile" }));
|
|
const ids = result.results.map((row) => row.id);
|
|
expect(ids).toContain(taskId);
|
|
expect(ids.indexOf(agentId)).toBeLessThan(ids.indexOf(taskId));
|
|
expect(ids.indexOf(projectId)).toBeLessThan(ids.indexOf(taskId));
|
|
expect((await svc.search(companyId, companySearchQuerySchema.parse({ q: '""' }))).results).toEqual([]);
|
|
});
|
|
|
|
it("ranks exact issue identifiers before weaker title matches", async () => {
|
|
const companyId = await createCompany();
|
|
const exactId = await createIssue(companyId, {
|
|
identifier: "TST-42",
|
|
title: "Backend endpoint",
|
|
});
|
|
await createIssue(companyId, {
|
|
identifier: "TST-43",
|
|
title: "TST-42 mentioned in title only",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "TST-42" }));
|
|
|
|
expect(result.results[0]?.id).toBe(exactId);
|
|
expect(result.results[0]?.matchedFields).toContain("identifier");
|
|
});
|
|
|
|
it("ranks phrase before reordered title words and rejects partial matches", async () => {
|
|
const companyId = await createCompany();
|
|
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
|
const partialTokenId = await createIssue(companyId, {
|
|
identifier: "TST-50",
|
|
title: "Alpha-only deployment",
|
|
updatedAt: new Date(base + 3_000),
|
|
});
|
|
const allTokenId = await createIssue(companyId, {
|
|
identifier: "TST-51",
|
|
title: "Alpha rollout beta",
|
|
updatedAt: new Date(base + 2_000),
|
|
});
|
|
const phraseId = await createIssue(companyId, {
|
|
identifier: "TST-52",
|
|
title: "Alpha beta deployment",
|
|
updatedAt: new Date(base + 1_000),
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "alpha beta", scope: "issues" }));
|
|
|
|
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId]);
|
|
expect(result.results.map((row) => row.id)).not.toContain(partialTokenId);
|
|
});
|
|
|
|
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
|
|
const companyId = await createCompany();
|
|
const issueId = await createIssue(companyId, {
|
|
identifier: "TST-7",
|
|
title: "Checkout semantics",
|
|
description: "Atomic ownership is enforced here.",
|
|
});
|
|
const commentId = randomUUID();
|
|
await db.insert(issueComments).values({
|
|
id: commentId,
|
|
companyId,
|
|
issueId,
|
|
body: "The ranking snippet should explain why this thread matched.",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "checkout snippet" }));
|
|
const match = result.results.find((item) => item.id === issueId);
|
|
|
|
expect(match).toBeTruthy();
|
|
expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"]));
|
|
expect(match?.href).toContain(`#comment-${commentId}`);
|
|
expect(match?.snippets.some((snippet) => snippet.field === "comment" && /snippet/i.test(snippet.text))).toBe(true);
|
|
expect(match?.snippets.find((snippet) => snippet.field === "comment")?.highlights.length).toBeGreaterThan(0);
|
|
expect(result.countsByType.comment).toBe(1);
|
|
});
|
|
|
|
it("searches issue documents and returns document metadata for snippets", async () => {
|
|
const companyId = await createCompany();
|
|
const issueId = await createIssue(companyId, {
|
|
identifier: "TST-8",
|
|
title: "Adapter manager",
|
|
});
|
|
const documentId = randomUUID();
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Hermes Parser Plan",
|
|
latestBody: "The external adapter parser should be discovered from the plugin package.",
|
|
format: "markdown",
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Hermes parser", scope: "documents" }));
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
expect(result.results[0]?.id).toBe(issueId);
|
|
expect(result.results[0]?.matchedFields).toContain("document");
|
|
expect(result.results[0]?.href).toContain("#document-plan");
|
|
expect(result.results[0]?.snippet).toMatch(/parser/i);
|
|
expect(result.results[0]?.snippets[0]).toMatchObject({ field: "document", label: "Hermes Parser Plan" });
|
|
expect(result.results[0]?.snippets[0]?.highlights.length).toBeGreaterThan(0);
|
|
expect(result.countsByType.document).toBe(1);
|
|
});
|
|
|
|
it("searches artifact projections through the artifacts scope", async () => {
|
|
const companyId = await createCompany();
|
|
const agentId = await createAgent(companyId, { name: "Artifact Writer" });
|
|
const issueId = await createIssue(companyId, {
|
|
identifier: "TST-88",
|
|
title: "Produce artifact",
|
|
});
|
|
const documentId = randomUUID();
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Launch Artifact Brief",
|
|
latestBody: "The searchable artifact body mentions a comet-tail preview.",
|
|
format: "markdown",
|
|
createdByAgentId: agentId,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "brief",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "comet-tail", scope: "artifacts" }));
|
|
|
|
expect(result.results).toHaveLength(1);
|
|
expect(result.results[0]).toMatchObject({
|
|
type: "artifact",
|
|
title: "Launch Artifact Brief",
|
|
href: expect.stringContaining("#document-brief"),
|
|
artifact: expect.objectContaining({
|
|
mediaKind: "document",
|
|
issueIdentifier: "TST-88",
|
|
}),
|
|
});
|
|
expect(result.results[0]?.snippet).toMatch(/comet tail/i);
|
|
expect(result.countsByType).toEqual({ issue: 0, comment: 0, document: 0, artifact: 1, agent: 0, project: 0 });
|
|
});
|
|
|
|
it("does not pass high-offset search fetch windows through to artifact query validation", async () => {
|
|
const companyId = await createCompany();
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({
|
|
q: "artifact",
|
|
scope: "artifacts",
|
|
limit: "50",
|
|
offset: "75",
|
|
}));
|
|
|
|
expect(result.results).toEqual([]);
|
|
expect(result.countsByType.artifact).toBe(0);
|
|
});
|
|
|
|
it("applies issue filters before sorting and pagination", async () => {
|
|
const companyId = await createCompany();
|
|
const agentId = await createAgent(companyId, { name: "Needle engineer" });
|
|
const projectId = await createProject(companyId, { name: "Needle project" });
|
|
const labelId = await createLabel(companyId, { name: "Needle label" });
|
|
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
|
const newestMatch = await createIssue(companyId, {
|
|
identifier: "TST-30",
|
|
title: "Needle newest",
|
|
status: "todo",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
projectId,
|
|
updatedAt: new Date(base + 3_000),
|
|
});
|
|
const olderMatch = await createIssue(companyId, {
|
|
identifier: "TST-31",
|
|
title: "Needle older",
|
|
status: "todo",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
projectId,
|
|
updatedAt: new Date(base + 2_000),
|
|
});
|
|
const statusDecoy = await createIssue(companyId, {
|
|
identifier: "TST-32",
|
|
title: "Needle done",
|
|
status: "done",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
projectId,
|
|
updatedAt: new Date(base + 4_000),
|
|
});
|
|
await db.insert(issueLabels).values([
|
|
{ companyId, issueId: newestMatch, labelId },
|
|
{ companyId, issueId: olderMatch, labelId },
|
|
{ companyId, issueId: statusDecoy, labelId },
|
|
]);
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({
|
|
q: "needle",
|
|
status: "todo",
|
|
priority: "high",
|
|
assigneeAgentId: agentId,
|
|
projectId,
|
|
labelId,
|
|
updatedAfter: new Date(base + 1_000).toISOString(),
|
|
sort: "updated",
|
|
limit: "1",
|
|
offset: "1",
|
|
}));
|
|
|
|
expect(result.results.map((row) => row.id)).toEqual([olderMatch]);
|
|
expect(result.countsByType.issue).toBe(2);
|
|
expect(result.countsByType.agent).toBe(0);
|
|
expect(result.countsByType.project).toBe(0);
|
|
expect(result.filterOptionCounts.status.todo).toBe(2);
|
|
expect(result.filterOptionCounts.status.done).toBe(1);
|
|
expect(result.hasMore).toBe(false);
|
|
});
|
|
|
|
it("returns issue rows for filter-only searches", async () => {
|
|
const companyId = await createCompany();
|
|
const agentId = await createAgent(companyId, { name: "Filter owner" });
|
|
const matchingIssue = await createIssue(companyId, {
|
|
identifier: "TST-34",
|
|
title: "Filtered task",
|
|
status: "todo",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await createIssue(companyId, {
|
|
identifier: "TST-35",
|
|
title: "Filtered decoy",
|
|
status: "done",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await createAgent(companyId, { name: "Todo" });
|
|
await createProject(companyId, { name: "Todo" });
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({
|
|
q: "",
|
|
status: "todo",
|
|
assigneeAgentId: agentId,
|
|
}));
|
|
|
|
expect(result.results.map((row) => row.id)).toEqual([matchingIssue]);
|
|
expect(result.countsByType.issue).toBe(1);
|
|
expect(result.countsByType.agent).toBe(0);
|
|
expect(result.countsByType.project).toBe(0);
|
|
expect(result.results[0]?.snippets).toEqual([]);
|
|
});
|
|
|
|
it("returns zero-result loosen data and suppresses agent/project rows while issue filters are active", async () => {
|
|
const companyId = await createCompany();
|
|
await createAgent(companyId, { name: "Needle agent", capabilities: "Needle capabilities" });
|
|
await createProject(companyId, { name: "Needle project", description: "Needle roadmap" });
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", status: "todo" }));
|
|
|
|
expect(result.results).toEqual([]);
|
|
expect(result.countsByType.agent).toBe(0);
|
|
expect(result.countsByType.project).toBe(0);
|
|
expect(result.zeroResults).toMatchObject({
|
|
unfilteredTotal: 2,
|
|
loosenSuggestions: [
|
|
{ filter: "status", values: ["todo"], resultCount: 2, additionalCount: 2 },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("does not leak hidden issue-backed artifacts", async () => {
|
|
const companyId = await createCompany();
|
|
const agentId = await createAgent(companyId, { name: "Artifact Writer" });
|
|
const visibleIssueId = await createIssue(companyId, {
|
|
identifier: "TST-33",
|
|
title: "Visible artifact holder",
|
|
});
|
|
const hiddenIssueId = await createIssue(companyId, {
|
|
identifier: "TST-34",
|
|
title: "Hidden artifact holder",
|
|
hiddenAt: new Date(),
|
|
});
|
|
const visibleDocumentId = randomUUID();
|
|
const hiddenDocumentId = randomUUID();
|
|
await db.insert(documents).values([
|
|
{
|
|
id: visibleDocumentId,
|
|
companyId,
|
|
title: "Visible Artifact",
|
|
latestBody: "Searchable artifact body",
|
|
format: "markdown",
|
|
createdByAgentId: agentId,
|
|
},
|
|
{
|
|
id: hiddenDocumentId,
|
|
companyId,
|
|
title: "Hidden Artifact",
|
|
latestBody: "Searchable artifact body",
|
|
format: "markdown",
|
|
createdByAgentId: agentId,
|
|
},
|
|
]);
|
|
await db.insert(issueDocuments).values([
|
|
{ companyId, issueId: visibleIssueId, documentId: visibleDocumentId, key: "visible" },
|
|
{ companyId, issueId: hiddenIssueId, documentId: hiddenDocumentId, key: "hidden" },
|
|
]);
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "artifact", scope: "artifacts" }));
|
|
|
|
expect(result.results.map((row) => row.artifact?.issueId)).toEqual([visibleIssueId]);
|
|
expect(result.countsByType.artifact).toBe(1);
|
|
});
|
|
|
|
it("excludes hidden issues and other companies' data", async () => {
|
|
const companyId = await createCompany("Visible Co");
|
|
const otherCompanyId = await createCompany("Other Co");
|
|
const visibleId = await createIssue(companyId, {
|
|
identifier: "VIS-1",
|
|
title: "Visible needle",
|
|
});
|
|
await createIssue(companyId, {
|
|
identifier: "HID-1",
|
|
title: "Hidden needle",
|
|
hiddenAt: new Date(),
|
|
});
|
|
await createIssue(companyId, {
|
|
identifier: "HAR-1",
|
|
title: "Harness needle",
|
|
harnessKind: "skill_test",
|
|
});
|
|
await createIssue(otherCompanyId, {
|
|
identifier: "OTH-1",
|
|
title: "Other company needle",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle" }));
|
|
|
|
expect(result.results.map((item) => item.id)).toEqual([visibleId]);
|
|
});
|
|
|
|
it("treats bare SQL wildcard characters as literals instead of match-all queries", async () => {
|
|
const companyId = await createCompany();
|
|
const issueId = await createIssue(companyId, {
|
|
identifier: "TST-20",
|
|
title: "Plain issue target",
|
|
description: "Plain issue description",
|
|
});
|
|
await db.insert(issueComments).values({
|
|
companyId,
|
|
issueId,
|
|
body: "Plain comment body",
|
|
});
|
|
const documentId = randomUUID();
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plain document",
|
|
latestBody: "Plain document body",
|
|
format: "markdown",
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plain",
|
|
});
|
|
await createAgent(companyId, {
|
|
name: "Plain Agent",
|
|
role: "engineer",
|
|
capabilities: "Plain agent capabilities",
|
|
});
|
|
await createProject(companyId, {
|
|
name: "Plain Project",
|
|
description: "Plain project description",
|
|
});
|
|
|
|
for (const q of ["%", "_", "\\"]) {
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q }));
|
|
expect(result.results, `q=${q}`).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("matches percent characters literally across issue, comment, document, agent, and project results", async () => {
|
|
const companyId = await createCompany();
|
|
const issueMatchId = await createIssue(companyId, {
|
|
identifier: "TST-21",
|
|
title: "Release 100% checklist",
|
|
});
|
|
const issueDecoyId = await createIssue(companyId, {
|
|
identifier: "TST-22",
|
|
title: "Release 1000 checklist",
|
|
});
|
|
const commentMatchId = await createIssue(companyId, {
|
|
identifier: "TST-23",
|
|
title: "Comment literal holder",
|
|
});
|
|
const commentDecoyId = await createIssue(companyId, {
|
|
identifier: "TST-24",
|
|
title: "Comment decoy holder",
|
|
});
|
|
await db.insert(issueComments).values([
|
|
{
|
|
companyId,
|
|
issueId: commentMatchId,
|
|
body: "QA is 100% confident in this result.",
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: commentDecoyId,
|
|
body: "QA is 1000 confident in this result.",
|
|
},
|
|
]);
|
|
const documentMatchIssueId = await createIssue(companyId, {
|
|
identifier: "TST-25",
|
|
title: "Document literal holder",
|
|
});
|
|
const documentDecoyIssueId = await createIssue(companyId, {
|
|
identifier: "TST-26",
|
|
title: "Document decoy holder",
|
|
});
|
|
const documentMatchId = randomUUID();
|
|
const documentDecoyId = randomUUID();
|
|
await db.insert(documents).values([
|
|
{
|
|
id: documentMatchId,
|
|
companyId,
|
|
title: "Literal rollout",
|
|
latestBody: "Ship 100% complete adapter support.",
|
|
format: "markdown",
|
|
},
|
|
{
|
|
id: documentDecoyId,
|
|
companyId,
|
|
title: "Decoy rollout",
|
|
latestBody: "Ship 1000 complete adapter support.",
|
|
format: "markdown",
|
|
},
|
|
]);
|
|
await db.insert(issueDocuments).values([
|
|
{
|
|
companyId,
|
|
issueId: documentMatchIssueId,
|
|
documentId: documentMatchId,
|
|
key: "literal",
|
|
},
|
|
{
|
|
companyId,
|
|
issueId: documentDecoyIssueId,
|
|
documentId: documentDecoyId,
|
|
key: "decoy",
|
|
},
|
|
]);
|
|
const agentMatchId = await createAgent(companyId, {
|
|
name: "100% Specialist",
|
|
role: "engineer",
|
|
});
|
|
const agentDecoyId = await createAgent(companyId, {
|
|
name: "1000 Specialist",
|
|
role: "engineer",
|
|
});
|
|
const projectMatchId = await createProject(companyId, {
|
|
name: "100% Launch Plan",
|
|
});
|
|
const projectDecoyId = await createProject(companyId, {
|
|
name: "1000 Launch Plan",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "100%" }));
|
|
const ids = result.results.map((row) => row.id);
|
|
|
|
expect(ids).toEqual(expect.arrayContaining([
|
|
issueMatchId,
|
|
commentMatchId,
|
|
documentMatchIssueId,
|
|
agentMatchId,
|
|
projectMatchId,
|
|
]));
|
|
expect(ids).not.toEqual(expect.arrayContaining([
|
|
issueDecoyId,
|
|
commentDecoyId,
|
|
documentDecoyIssueId,
|
|
agentDecoyId,
|
|
projectDecoyId,
|
|
]));
|
|
});
|
|
|
|
it("applies offset after merging cross-type result ranking", async () => {
|
|
const companyId = await createCompany();
|
|
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
|
const agentIds = await Promise.all([
|
|
createAgent(companyId, { name: "Needle agent 1", updatedAt: new Date(base + 6_000) }),
|
|
createAgent(companyId, { name: "Needle agent 2", updatedAt: new Date(base + 5_000) }),
|
|
createAgent(companyId, { name: "Needle agent 3", updatedAt: new Date(base + 4_000) }),
|
|
]);
|
|
const projectIds = await Promise.all([
|
|
createProject(companyId, { name: "Needle project 1", updatedAt: new Date(base + 3_000) }),
|
|
createProject(companyId, { name: "Needle project 2", updatedAt: new Date(base + 2_000) }),
|
|
createProject(companyId, { name: "Needle project 3", updatedAt: new Date(base + 1_000) }),
|
|
]);
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" }));
|
|
|
|
expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]);
|
|
expect(result.countsByType).toEqual({ issue: 0, comment: 0, document: 0, artifact: 0, agent: 3, project: 3 });
|
|
expect(result.hasMore).toBe(true);
|
|
});
|
|
|
|
it("escapes underscore and backslash characters in issue phrase and token patterns", async () => {
|
|
const companyId = await createCompany();
|
|
const literalId = await createIssue(companyId, {
|
|
identifier: "TST-27",
|
|
title: "Literal foo_bar path c:\\tmp",
|
|
});
|
|
const decoyId = await createIssue(companyId, {
|
|
identifier: "TST-28",
|
|
title: "Decoy fooXbar path c:tmp",
|
|
});
|
|
|
|
for (const q of ["foo_bar", "c:\\tmp"]) {
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q, scope: "issues" }));
|
|
const ids = result.results.map((row) => row.id);
|
|
expect(ids, `q=${q}`).toContain(literalId);
|
|
expect(ids, `q=${q}`).not.toContain(decoyId);
|
|
}
|
|
});
|
|
|
|
it("does not interpret short UI terms as the middle of unrelated words", async () => {
|
|
const companyId = await createCompany();
|
|
const target = await createIssue(companyId, { title: "Improve mobile UI" });
|
|
await createIssue(companyId, { title: "Build billing reports" });
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "UI" }));
|
|
expect(result.results.map((row) => row.id)).toEqual([target]);
|
|
});
|
|
|
|
it("keeps typo fallback inside the requested filters", async () => {
|
|
const companyId = await createCompany();
|
|
await createIssue(companyId, { title: "Mibile API", status: "done" });
|
|
const target = await createIssue(companyId, { title: "Mobile API", status: "todo" });
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile api", status: "todo" }));
|
|
expect(result.results.map((row) => row.id)).toEqual([target]);
|
|
});
|
|
|
|
it("keeps exact title hits on the task and chooses the most complete context evidence", async () => {
|
|
const companyId = await createCompany();
|
|
const task = await createIssue(companyId, { title: "Aurora callback" });
|
|
await db.insert(issueComments).values({ companyId, issueId: task, body: "Aurora callback is mentioned here too." });
|
|
const exact = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Aurora callback" }));
|
|
expect(exact.results[0]?.href).not.toContain("#comment-");
|
|
|
|
const holder = await createIssue(companyId, { title: "Connection investigation" });
|
|
await db.insert(issueComments).values({ companyId, issueId: holder, body: "Aurora was discussed.", updatedAt: new Date("2026-06-01") });
|
|
const strongest = randomUUID();
|
|
await db.insert(issueComments).values({ id: strongest, companyId, issueId: holder,
|
|
body: "Aurora loses the callback state.", updatedAt: new Date("2026-01-01") });
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "aurora state callback" }));
|
|
expect(result.results[0]?.href).toContain(`#comment-${strongest}`);
|
|
expect(result.results[0]?.snippet).toContain("state");
|
|
});
|
|
|
|
it.each(["comment", "document"] as const)("preserves %s evidence when title and identifier both match", async (source) => {
|
|
const companyId = await createCompany();
|
|
const task = await createIssue(companyId, {
|
|
identifier: "CTX-123", title: "CTX callback investigation", description: "CTX callback details",
|
|
});
|
|
const sourceId = randomUUID();
|
|
if (source === "comment") {
|
|
await db.insert(issueComments).values({ id: sourceId, companyId, issueId: task, body: "The missing signal is quasar." });
|
|
} else {
|
|
await db.insert(documents).values({ id: sourceId, companyId, title: "Investigation plan", latestBody: "The missing signal is quasar.", format: "markdown" });
|
|
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: sourceId, key: "plan" });
|
|
}
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "CTX quasar" }));
|
|
const match = result.results.find((row) => row.id === task)!;
|
|
expect(match.matchedFields).toEqual(expect.arrayContaining(["identifier", "title", source]));
|
|
expect(match.score).toBeGreaterThanOrEqual(2000);
|
|
expect(match.score).toBeLessThan(3000);
|
|
expect(match.snippets).toHaveLength(2);
|
|
expect(match.snippets[0]).toMatchObject({ field: source, text: expect.stringContaining("quasar") });
|
|
expect(match.snippet).toContain("quasar");
|
|
expect(match.href).toContain(source === "comment" ? `#comment-${sourceId}` : "#document-plan");
|
|
});
|
|
|
|
it("reflects edits, deleted comments and document updates immediately", async () => {
|
|
const companyId = await createCompany();
|
|
const task = await createIssue(companyId, { title: "Old uniquequartz title" });
|
|
const find = () => svc.search(companyId, companySearchQuerySchema.parse({ q: '"uniquequartz"' }));
|
|
expect((await find()).results.map((row) => row.id)).toEqual([task]);
|
|
await db.update(issues).set({ title: "New title" }).where(sql`${issues.id} = ${task}`);
|
|
expect((await find()).results).toEqual([]);
|
|
const comment = randomUUID();
|
|
await db.insert(issueComments).values({ id: comment, companyId, issueId: task, body: "uniquequartz" });
|
|
expect((await find()).results.map((row) => row.id)).toEqual([task]);
|
|
await db.update(issueComments).set({ deletedAt: new Date() }).where(sql`${issueComments.id} = ${comment}`);
|
|
expect((await find()).results).toEqual([]);
|
|
const doc = randomUUID();
|
|
await db.insert(documents).values({ id: doc, companyId, title: "Findings", latestBody: "uniquequartz", format: "markdown" });
|
|
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: doc, key: "plan" });
|
|
expect((await find()).results.map((row) => row.id)).toEqual([task]);
|
|
await db.update(documents).set({ latestBody: "Updated findings" }).where(sql`${documents.id} = ${doc}`);
|
|
expect((await find()).results).toEqual([]);
|
|
});
|
|
|
|
it("uses pg_trgm for conservative fuzzy title matches", async () => {
|
|
const companyId = await createCompany();
|
|
const issueId = await createIssue(companyId, {
|
|
identifier: "TST-9",
|
|
title: "Onboarding wizard polish",
|
|
});
|
|
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "onbordng wizard" }));
|
|
|
|
expect(result.results[0]?.id).toBe(issueId);
|
|
expect(result.results[0]?.matchedFields).toContain("title");
|
|
});
|
|
|
|
it("matches transposition typos against multi-word titles", async () => {
|
|
const companyId = await createCompany();
|
|
const searchIssueId = await createIssue(companyId, {
|
|
identifier: "TST-10",
|
|
title: "Improve search performance",
|
|
});
|
|
const mobileIssueId = await createIssue(companyId, {
|
|
identifier: "TST-11",
|
|
title: "Polish mobile navigation",
|
|
});
|
|
const otherIssueId = await createIssue(companyId, {
|
|
identifier: "TST-12",
|
|
title: "Refactor billing reports",
|
|
});
|
|
|
|
const transpositionCases: Array<{ query: string; expectedId: string; rejected: string }> = [
|
|
{ query: "serach", expectedId: searchIssueId, rejected: otherIssueId },
|
|
{ query: "mibile", expectedId: mobileIssueId, rejected: otherIssueId },
|
|
{ query: "mobail", expectedId: mobileIssueId, rejected: otherIssueId },
|
|
];
|
|
|
|
for (const { query, expectedId, rejected } of transpositionCases) {
|
|
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: query }));
|
|
const ids = result.results.map((row) => row.id);
|
|
expect(ids, `query=${query}`).toContain(expectedId);
|
|
expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected);
|
|
}
|
|
});
|
|
|
|
it("pushes private issue filtering into list, count, and company search queries", async () => {
|
|
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
|
try {
|
|
const companyId = await createCompany();
|
|
const ownerId = `owner-${randomUUID()}`;
|
|
const otherId = `other-${randomUUID()}`;
|
|
const granteeId = `grantee-${randomUUID()}`;
|
|
const openId = await createIssue(companyId, { title: "privacy needle open" });
|
|
const privateId = randomUUID();
|
|
await createIssue(companyId, {
|
|
id: privateId,
|
|
title: "privacy needle private",
|
|
visibility: "private",
|
|
privacyRootIssueId: privateId,
|
|
responsibleUserId: ownerId,
|
|
});
|
|
await db.insert(issueAccessGrants).values({
|
|
issueId: privateId,
|
|
subjectType: "user",
|
|
subjectId: granteeId,
|
|
source: "explicit",
|
|
grantedByUserId: ownerId,
|
|
});
|
|
|
|
const issuesSvc = issueService(db);
|
|
const conditionFor = (userId: string) => issueReadSqlCondition(db, {
|
|
type: "board",
|
|
userId,
|
|
source: "session",
|
|
});
|
|
const otherCondition = await conditionFor(otherId);
|
|
const ownerCondition = await conditionFor(ownerId);
|
|
const granteeCondition = await conditionFor(granteeId);
|
|
|
|
await expect(issuesSvc.list(companyId, { readCondition: otherCondition })).resolves.toMatchObject([
|
|
{ id: openId },
|
|
]);
|
|
await expect(issuesSvc.count(companyId, { readCondition: otherCondition })).resolves.toBe(1);
|
|
await expect(issuesSvc.count(companyId, { readCondition: ownerCondition })).resolves.toBe(2);
|
|
await expect(issuesSvc.count(companyId, { readCondition: granteeCondition })).resolves.toBe(2);
|
|
|
|
const result = await svc.search(
|
|
companyId,
|
|
companySearchQuerySchema.parse({ q: "privacy needle", scope: "issues" }),
|
|
{ issueReadCondition: otherCondition },
|
|
);
|
|
expect(result.results.map((row) => row.id)).toEqual([openId]);
|
|
} finally {
|
|
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
|
}
|
|
});
|
|
|
|
it("pushes private project filtering into project search and counts", async () => {
|
|
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
|
try {
|
|
const companyId = await createCompany();
|
|
const memberId = `member-${randomUUID()}`;
|
|
const outsiderId = `outsider-${randomUUID()}`;
|
|
const openId = await createProject(companyId, { name: "privacy project open" });
|
|
const privateId = await createProject(companyId, {
|
|
name: "privacy project private",
|
|
visibility: "private",
|
|
});
|
|
await db.insert(projectAccessMembers).values({
|
|
companyId,
|
|
projectId: privateId,
|
|
subjectType: "user",
|
|
subjectId: memberId,
|
|
});
|
|
const conditionFor = (userId: string) => projectReadSqlCondition(db, {
|
|
type: "board",
|
|
userId,
|
|
source: "session",
|
|
});
|
|
const query = companySearchQuerySchema.parse({ q: "privacy project", scope: "projects" });
|
|
const outsiderResult = await svc.search(companyId, query, {
|
|
projectReadCondition: await conditionFor(outsiderId),
|
|
});
|
|
expect(outsiderResult.results.map((row) => row.id)).toEqual([openId]);
|
|
expect(outsiderResult.countsByType.project).toBe(1);
|
|
|
|
const memberResult = await svc.search(companyId, query, {
|
|
projectReadCondition: await conditionFor(memberId),
|
|
});
|
|
expect(memberResult.results.map((row) => row.id)).toEqual(expect.arrayContaining([openId, privateId]));
|
|
expect(memberResult.countsByType.project).toBe(2);
|
|
} finally {
|
|
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
|
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
|
}
|
|
});
|
|
});
|