feat(search): A2-A9 — unified OR-relevance engine, RRF, pagination (#529)

Rewrite SearchService.searchPage into ONE engine for web-UI, MCP agent and
share:

- A2 server-side query parser (search-query-parser.ts): quote-aware
  tokenizer, leading +/- operators (internal -,.,: literal), "phrase",
  metachar-stripped bare terms; tsquery built as a parameterized AST via
  SQL ||/&&/!! (never string-concat). only-negation/empty short-circuit.
- A3 match=auto routes identifier-like terms (10.31.41, esp32,
  WB-MGE-30D86B) to the substring/trigram branch, words to FTS; word/
  prefix/substring overrides.
- A4 RRF (k=60) fuses the FTS branch (ts_rank_cd) and substring branch
  (title-exact>title-sub>text tier) by RANK; ORDER BY rrf DESC, id.
- A5 exact permission-filtered total (fail-closed via filterAccessiblePageIds
  with the #348 hasRestricted fast-path), CANDIDATE_CAP fusion window,
  offset/limit, hasMore, truncatedAtCap.
- A6 single path (spaceId/share/creatorId/titleOnly/parentPageId/match);
  share uses getPageAndDescendantsExcludingRestricted.
- A7 response superset per hit (id/pageId/slugId/icon/title/space/…/rank/
  highlight/snippet/path/score/matchedFields/matchedTerms).
- A8 buildAncestorPaths now skips deleted + cross-space ancestors.
- A9 DTOs: match, offset, total/hasMore/truncatedAtCap/query/matchedFields.

SEARCH_MODE=or|and toggles the parser; SEARCH_CANDIDATE_CAP tunes the
window. Legacy lookup unit/int specs replaced by parser unit tests + a
13-criteria integration spec on real pg (incl. a permission mutation guard
and a fail-closed propagation test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 02:00:25 +03:00
parent 87d5d7ac26
commit a4ea22a7f5
10 changed files with 1355 additions and 1270 deletions
@@ -1,33 +1,56 @@
import { Space } from '@docmost/db/types/entity.types';
export class SearchResponseDto {
// #529 A7 — the single per-hit SUPERSET returned by the unified search engine.
// The web-UI reads id/highlight/icon/space/title/…; the MCP agent maps id→pageId
// and reads snippet/score/path. `rank`/`highlight` are null for substring-only
// hits (the web already falls back). Nothing the legacy web response carried is
// dropped.
export class SearchResultDto {
id: string;
title: string;
// Alias of `id` for the MCP layer (it addresses pages by pageId).
pageId: string;
slugId: string;
icon: string;
parentPageId: string;
title: string;
space?: Partial<Space>;
creatorId: string;
rank: number;
highlight: string;
createdAt: Date;
updatedAt: Date;
space: Partial<Space>;
// ts_rank_cd of the FTS branch; null for substring-only hits.
rank: number | null;
// ts_headline marked HTML; null for substring-only hits.
highlight: string | null;
// Plain windowed snippet around the match (empty for titleOnly).
snippet: string;
// Ancestor titles root → direct parent ([] for a root page).
path: string[];
// Per-response ordering proxy (falls back to rank).
score: number;
// Which fields matched: 'title' and/or 'text'.
matchedFields: string[];
// Which parsed positive/required terms this hit matched.
matchedTerms: string[];
}
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
// Additive to the FTS response: carries the location (`path`), a windowed
// `snippet` around the first match and a per-response sort `score`. The MCP
// layer maps `id → pageId`; `slugId` is never exposed.
export class SearchLookupResponseDto {
id: string;
slugId: string;
title: string;
parentPageId: string | null;
// Ancestor titles from the space root down to the direct parent; [] for a
// root page.
path: string[];
// ~300–500 chars around the first match (or a leading text window / extended
// ts_headline fallback).
snippet: string;
// 0..1 float, meaningful ONLY for sorting within one response.
score: number;
// The paginated envelope (A5). `total` is the EXACT permission-filtered count of
// pages matching the positive lexical query (fail-closed). `hasMore` is true when
// more results exist WITHIN the fusion window; `truncatedAtCap` signals the match
// set exceeded CANDIDATE_CAP and the tail is unreachable by pagination.
export class SearchResponseDto {
items: SearchResultDto[];
total: number;
hasMore: boolean;
truncatedAtCap: boolean;
offset: number;
query: {
raw: string;
parsed: {
positive: string[];
required: string[];
excluded: string[];
reason?: string;
};
mode: 'or' | 'and';
match: string;
};
}
@@ -1,5 +1,6 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
@@ -11,6 +12,13 @@ export class SearchDTO {
@IsString()
query: string;
// #529 A3 — match mode. `auto` (default) routes identifier-like terms
// (10.31.41, esp32, WB-MGE-30D86B) to the substring/trigram branch and words
// to full-text; `word`/`prefix`/`substring` are explicit overrides.
@IsOptional()
@IsIn(['auto', 'word', 'prefix', 'substring'])
match?: 'auto' | 'word' | 'prefix' | 'substring';
@IsOptional()
@IsString()
spaceId: string;
@@ -0,0 +1,115 @@
import { parseSearchQuery, hasPositiveRecall } from './search-query-parser';
describe('parseSearchQuery — tokenization & operators (A2)', () => {
it('splits on whitespace into positive terms (OR recall)', () => {
const p = parseSearchQuery('стамбул роснефть');
expect(p.positive.map((t) => t.text)).toEqual(['стамбул', 'роснефть']);
expect(p.required).toEqual([]);
expect(p.excluded).toEqual([]);
expect(p.mode).toBe('or');
});
it('treats +term as required and -term as excluded', () => {
const p = parseSearchQuery('+кофейня -архив');
expect(p.required.map((t) => t.text)).toEqual(['кофейня']);
expect(p.excluded.map((t) => t.text)).toEqual(['архив']);
expect(p.positive).toEqual([]);
});
it('keeps a leading-operator-free hyphen/dot/colon token as ONE literal term', () => {
// WB-MGE-30D86B, 10.0.12.5, a:b — internal -,.,: are literal, one term each.
expect(parseSearchQuery('WB-MGE-30D86B').positive[0].text).toBe(
'WB-MGE-30D86B',
);
expect(parseSearchQuery('10.0.12.5').positive[0].text).toBe('10.0.12.5');
expect(parseSearchQuery('host:8080').positive[0].text).toBe('host:8080');
});
it('only a LEADING +/- is an operator; -архив excludes архив', () => {
const p = parseSearchQuery('-архив');
expect(p.excluded.map((t) => t.text)).toEqual(['архив']);
expect(p.positive).toEqual([]);
});
it('drops a bare "-" / "+" and an all-operator remainder', () => {
const p = parseSearchQuery('- + foo -- ++');
expect(p.positive.map((t) => t.text)).toEqual(['foo']);
expect(p.required).toEqual([]);
expect(p.excluded).toEqual([]);
});
it('parses a quoted phrase as one adjacency term', () => {
const p = parseSearchQuery('"воздушный шар" кофе');
expect(p.positive[0]).toMatchObject({ text: 'воздушный шар', branch: 'phrase' });
expect(p.positive[1].text).toBe('кофе');
});
it('applies +/- to a phrase', () => {
const req = parseSearchQuery('+"воздушный шар"');
expect(req.required[0]).toMatchObject({ text: 'воздушный шар', branch: 'phrase' });
const exc = parseSearchQuery('-"воздушный шар"');
expect(exc.excluded[0]).toMatchObject({ text: 'воздушный шар', branch: 'phrase' });
});
it('drops an unbalanced quote token', () => {
const p = parseSearchQuery('kafka "unclosed here');
expect(p.positive.map((t) => t.text)).toEqual(['kafka']);
});
it('strips tsquery metacharacters from a bare FTS term (no 500)', () => {
const p = parseSearchQuery('foo|bar');
// `|` is not an identifier signal → FTS branch; the metachar is stripped so
// the term becomes the two words that survive.
expect(p.positive[0].branch === 'fts' || p.positive[0].branch === 'ftsPrefix').toBe(true);
expect(p.positive[0].text).toBe('foo bar');
});
});
describe('parseSearchQuery — match=auto classification (A3)', () => {
it('routes identifier-like terms to the substring branch', () => {
expect(parseSearchQuery('10.31.41').positive[0].branch).toBe('substring');
expect(parseSearchQuery('esp32').positive[0].branch).toBe('substring');
expect(parseSearchQuery('WB-MGE-30D86B').positive[0].branch).toBe('substring');
});
it('routes purely-alphabetic words to the FTS (prefix) branch', () => {
expect(parseSearchQuery('печат').positive[0].branch).toBe('ftsPrefix');
expect(parseSearchQuery('ресторан').positive[0].branch).toBe('ftsPrefix');
});
it('explicit match overrides: word / prefix / substring', () => {
expect(parseSearchQuery('печат', { match: 'word' }).positive[0].branch).toBe('fts');
expect(parseSearchQuery('печат', { match: 'prefix' }).positive[0].branch).toBe(
'ftsPrefix',
);
expect(parseSearchQuery('печат', { match: 'substring' }).positive[0].branch).toBe(
'substring',
);
});
});
describe('parseSearchQuery — reasons & recall', () => {
it('only-negation yields reason only-negation and no positive recall', () => {
const p = parseSearchQuery('-архив');
expect(p.reason).toBe('only-negation');
expect(hasPositiveRecall(p)).toBe(false);
});
it('empty / whitespace / garbage yields reason empty', () => {
expect(parseSearchQuery('').reason).toBe('empty');
expect(parseSearchQuery(' ').reason).toBe('empty');
// A bare operator drops to nothing → empty (no exclusion survived).
expect(parseSearchQuery('+ -').reason).toBe('empty');
});
it('a required term alone IS positive recall (no reason)', () => {
const p = parseSearchQuery('+кофейня');
expect(p.reason).toBeUndefined();
expect(hasPositiveRecall(p)).toBe(true);
});
it('mode flag flows through', () => {
expect(parseSearchQuery('a b', { mode: 'and' }).mode).toBe('and');
expect(parseSearchQuery('a b').mode).toBe('or');
});
});
@@ -0,0 +1,224 @@
// #529 Phase A — server-side query parser (the SINGLE source of query semantics).
//
// Clients (web-UI, MCP agent, public share) send a RAW query string plus flags
// (`match`, `mode`); ALL query interpretation happens HERE, so every consumer
// gets identical operator/phrase/morphology behaviour. The parser is PURE (no
// SQL, no DB) so it is exhaustively unit-testable; SearchService turns the parsed
// AST into a parameterized tsquery/predicate tree (never string-concatenated SQL).
//
// Grammar (A2):
// - Whitespace splits tokens, but a double-quoted run is ONE token ("a b" is a
// phrase). An unbalanced quote is dropped.
// - A token is an OPERATOR token only when it STARTS with `+` or `-`, the
// remainder is non-empty, and the remainder is not itself only operators.
// `+`/`-` inside a token (`WB-MGE-30D86B`, `10.0.12.5`, `a:b`) is a LITERAL —
// the token is a single term. A bare `-`/`+` is dropped.
// - `"phrase"` → phrase term; `+"phrase"`/`-"phrase"` apply the operator to it.
// - Positive terms (bare + phrase, no operator) form the OR recall set.
// `+term` is a REQUIRED predicate, `-term` an EXCLUDED predicate (A2): both
// are applied in SQL WHERE against the whole candidate set, not folded into
// the positive tsquery.
// - Only-negation (no positive term) short-circuits to an empty result with
// reason `only-negation` (never runs a costly NOT-scan).
export type SearchMatchMode = 'auto' | 'word' | 'prefix' | 'substring';
export type SearchBooleanMode = 'or' | 'and';
// How a single term is matched against the index.
// - 'fts' : full-text lexeme, exact (no trailing prefix).
// - 'ftsPrefix' : full-text lexeme with a `:*` prefix match.
// - 'phrase' : an adjacency phrase (phraseto_tsquery).
// - 'substring' : a literal LOWER(f_unaccent(col)) LIKE '%needle%' branch
// (identifiers the tokenizer mangles: IPs, hostnames, IDs).
export type SearchTermBranch = 'fts' | 'ftsPrefix' | 'phrase' | 'substring';
export interface ParsedTerm {
// The user-visible term text, operator stripped, quotes removed. This is what
// `matchedTerms` echoes back per hit.
text: string;
branch: SearchTermBranch;
}
export interface ParsedQuery {
raw: string;
// OR-recall set (bare + phrase terms with no operator).
positive: ParsedTerm[];
// AND predicates (`+term`) — the candidate MUST match each of these.
required: ParsedTerm[];
// NOT predicates (`-term`) — the candidate must match NONE of these.
excluded: ParsedTerm[];
mode: SearchBooleanMode;
// Set only when the query yields no positive recall: 'empty' (nothing usable)
// or 'only-negation' (there were exclusions but no positive term).
reason?: 'empty' | 'only-negation';
}
interface RawToken {
op: '' | '+' | '-';
kind: 'word' | 'phrase';
text: string;
}
// tsquery metacharacters that must never reach to_tsquery from a bare term — they
// are what turned adversarial input into a 500 before (#139). Stripped for the FTS
// branch; the substring branch keeps them (they are literal there).
const TSQUERY_META = /[:&|!()*<>\\]+/g;
// A term is "identifier-like" when it carries a digit or one of . _ : / - AND is
// not purely alphabetic (letters only). Such tokens (10.31.41, esp32,
// WB-MGE-30D86B) are mangled by the FTS tokenizer, so `match: auto` routes them
// to the substring branch. A purely-alphabetic word (печат, ресторан) stays FTS.
const IDENTIFIER_SIGNAL = /[0-9._:/\\-]/;
const PURELY_ALPHA = /^\p{L}+$/u;
function isIdentifierLike(text: string): boolean {
return IDENTIFIER_SIGNAL.test(text) && !PURELY_ALPHA.test(text);
}
// Clean a bare term for the FTS branch: NFC-normalize, drop tsquery metacharacters
// and collapse whitespace. Returns '' when nothing usable remains.
export function cleanFtsLexeme(raw: string): string {
return (raw ?? '')
.normalize('NFC')
.replace(TSQUERY_META, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Split a raw query into tokens, honouring double quotes and a single leading
// +/- operator. Unbalanced quotes and bare operators are dropped.
function tokenize(raw: string): RawToken[] {
const tokens: RawToken[] = [];
const s = raw ?? '';
let i = 0;
const n = s.length;
const isSpace = (c: string) => /\s/.test(c);
while (i < n) {
// Skip leading whitespace.
while (i < n && isSpace(s[i])) i++;
if (i >= n) break;
let op: '' | '+' | '-' = '';
// A single leading +/- is a tentative operator. Only ONE leading operator is
// consumed; a second (`--x`) leaves `-x` as the remainder (a literal dash).
if (s[i] === '+' || s[i] === '-') {
op = s[i] as '+' | '-';
i++;
}
if (i < n && s[i] === '"') {
// Quoted phrase: read until the closing quote.
const close = s.indexOf('"', i + 1);
if (close === -1) {
// Unbalanced quote → drop this token and everything the open quote would
// have consumed (the rest of the string).
break;
}
const phrase = s.slice(i + 1, close);
i = close + 1;
if (phrase.trim().length > 0) {
tokens.push({ op, kind: 'phrase', text: phrase.trim() });
}
continue;
}
// Bare word: read until the next whitespace.
let j = i;
while (j < n && !isSpace(s[j])) j++;
const word = s.slice(i, j);
i = j;
// A bare operator (`-`/`+` with no remainder) or an all-operator remainder is
// dropped.
if (word.length === 0) continue;
if (op && /^[+-]+$/.test(word)) continue;
tokens.push({ op, kind: 'word', text: word });
}
return tokens;
}
// Resolve the match branch for a single term given the global match mode.
function branchForTerm(
text: string,
kind: 'word' | 'phrase',
mode: SearchMatchMode,
): SearchTermBranch {
if (kind === 'phrase') return 'phrase';
switch (mode) {
case 'word':
return 'fts';
case 'prefix':
return 'ftsPrefix';
case 'substring':
return 'substring';
case 'auto':
default:
// Identifiers the tokenizer mangles go to substring; words get a prefix
// FTS match (so `печат` still finds `печатать`, but `печат` no longer drags
// in `впечатления` because the russian stemmer anchors the stem).
return isIdentifierLike(text) ? 'substring' : 'ftsPrefix';
}
}
/**
* Parse a raw user query + flags into a structured, SQL-agnostic AST.
* Pure and total: never throws, always returns a ParsedQuery.
*/
export function parseSearchQuery(
raw: string,
opts: { match?: SearchMatchMode; mode?: SearchBooleanMode } = {},
): ParsedQuery {
const match: SearchMatchMode = opts.match ?? 'auto';
const mode: SearchBooleanMode = opts.mode ?? 'or';
const positive: ParsedTerm[] = [];
const required: ParsedTerm[] = [];
const excluded: ParsedTerm[] = [];
for (const tok of tokenize(raw)) {
// For an FTS branch, the token must survive metacharacter cleaning; for the
// substring/phrase branch the literal text is used. A term that cleans to
// nothing AND is not usable as a substring is dropped.
const branch = branchForTerm(tok.text, tok.kind, match);
let usableText: string;
if (branch === 'fts' || branch === 'ftsPrefix') {
usableText = cleanFtsLexeme(tok.text);
} else {
// phrase / substring keep the literal (trimmed) text.
usableText = tok.text.trim();
}
if (!usableText) continue;
const term: ParsedTerm = { text: usableText, branch };
if (tok.op === '+') required.push(term);
else if (tok.op === '-') excluded.push(term);
else positive.push(term);
}
const parsed: ParsedQuery = { raw: raw ?? '', positive, required, excluded, mode };
if (positive.length === 0) {
// Required terms with no positive recall still form a valid positive set (the
// required predicates ARE the recall). Only when there is neither a positive
// nor a required term is the query empty / only-negation.
if (required.length === 0) {
parsed.reason = excluded.length > 0 ? 'only-negation' : 'empty';
}
}
return parsed;
}
/**
* Does this parsed query have any positive recall to run? False means we must
* short-circuit to an empty result (with `reason`), never a costly NOT-only scan.
*/
export function hasPositiveRecall(parsed: ParsedQuery): boolean {
return parsed.positive.length > 0 || parsed.required.length > 0;
}
@@ -1,19 +1,14 @@
import {
computeLookupScore,
escapeLikePattern,
SearchLookupTier,
} from './search.service';
import { escapeLikePattern } from './search.service';
/**
* Pure-function coverage for the #443 agent-lookup helpers:
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
* everything);
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
* always outranks a weaker one regardless of the in-tier secondary signal.
* Pure-function coverage for `escapeLikePattern` — LIKE-metacharacter escaping so
* `%`/`_`/`\` are matched literally (the acceptance requirement that a query of
* `%` or `_` does NOT match everything, #529 acceptance #10). The substring
* branch's DB behaviour is covered by the integration spec.
*
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
* covered by the integration spec against the real schema.
* NOTE (#529): the old tiered `computeLookupScore` was replaced by RRF rank
* fusion in the unified engine, so its unit coverage moved to the integration
* ordering tests; only the escaping helper remains a pure unit here.
*/
describe('escapeLikePattern', () => {
it('escapes the LIKE metacharacters % _ and \\', () => {
@@ -43,53 +38,3 @@ describe('escapeLikePattern', () => {
expect(escapeLikePattern(null as any)).toBe('');
});
});
describe('computeLookupScore', () => {
it('keeps every score within (0, 1]', () => {
for (const tier of [
SearchLookupTier.TITLE_EXACT,
SearchLookupTier.TITLE_SUBSTRING,
SearchLookupTier.TEXT,
]) {
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
const s = computeLookupScore({ tier, secondary });
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThanOrEqual(1);
}
}
});
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
// Weak tier with a huge secondary must still lose to a strong tier with a
// tiny secondary — tiers dominate.
const strongLowSecondary = computeLookupScore({
tier: SearchLookupTier.TITLE_EXACT,
secondary: 0,
});
const weakHighSecondary = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 1e9,
});
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
});
it('within a tier a larger secondary sorts higher', () => {
const lo = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 0.1,
});
const hi = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 5,
});
expect(hi).toBeGreaterThan(lo);
});
it('treats a negative/absent secondary as 0', () => {
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
expect(
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
).toBe(zero);
});
});
@@ -1,74 +1,45 @@
import { SearchService } from './search.service';
/**
* Coverage for SearchService.searchPage query-mode selection (search.service.ts
* @25). searchPage chooses HOW the result set is scoped — by explicit space, by
* the authenticated user's member spaces, or by a share — and must return an
* empty set (without leaking data) for every disallowed combination.
* Unit coverage for SearchService.searchPage SCOPE-SECURITY early returns — the
* branches that must yield an empty result WITHOUT ever touching the DB, so they
* can leak nothing. The happy-path scope SQL (explicit space / member spaces /
* share id set) is covered against the real schema in the integration spec.
*
* The kysely query builder is mocked with the same chainable pattern as the
* existing search.service.spec.ts: every builder method returns the same builder
* and `.execute()` resolves the supplied rows. Each `.where(...)` call is
* recorded so we can assert exactly which scope clause was applied — that is the
* mutation-resistant signal that distinguishes one query mode from another.
*
* These specs catch cross-space / cross-workspace search leakage and
* share-scope bypass (data exposure).
* Every case here returns BEFORE the raw-SQL candidate query runs, so a bare `db`
* stub (never called) is enough — a call to it would itself be a failure signal.
*/
describe('SearchService.searchPage — query-mode selection', () => {
// Build a chainable selectFrom('pages') builder that records its calls. The
// builder is returned from `db.selectFrom` and is the single object every
// chained call mutates/returns, mirroring the existing spec's pattern.
function makeBuilder(rows: Array<{ id: string; highlight?: string }>) {
const builder: any = {};
builder.select = jest.fn(() => builder);
builder.where = jest.fn(() => builder);
builder.$if = jest.fn(() => builder);
builder.orderBy = jest.fn(() => builder);
builder.limit = jest.fn(() => builder);
builder.offset = jest.fn(() => builder);
builder.execute = jest.fn(async () => rows);
return builder;
}
describe('SearchService.searchPage — scope-security early returns', () => {
function makeService(opts?: {
rows?: Array<{ id: string; highlight?: string }>;
share?: any;
isRestricted?: boolean;
descendants?: Array<{ id: string }>;
memberSpaceIds?: string[];
}) {
const builder = makeBuilder(opts?.rows ?? []);
const db: any = {
selectFrom: jest.fn(() => builder),
};
// `getUserSpaceIdsQuery` returns a sub-query object that searchPage passes
// straight into `.where('spaceId', 'in', <subquery>)`. A sentinel is enough
// to assert the user-scoped branch was taken.
const userSpaceIdsQuery = { __userSpaceIdsQuery: true };
// A db that THROWS if touched — these branches must not reach SQL.
const db: any = new Proxy(
{},
{
get() {
throw new Error('db must not be touched on an empty-scope branch');
},
},
);
const pageRepo = {
// `.select((eb) => this.pageRepo.withSpace(eb))` — value ignored by stub.
withSpace: jest.fn(() => ({ __withSpace: true })),
getPageAndDescendantsExcludingRestricted: jest
.fn()
.mockResolvedValue(opts?.descendants ?? []),
getPageAndDescendantsExcludingRestricted: jest.fn(),
getPageAndDescendants: jest.fn(),
};
const shareRepo = {
findById: jest.fn().mockResolvedValue(opts?.share ?? null),
};
const spaceMemberRepo = {
getUserSpaceIdsQuery: jest.fn(() => userSpaceIdsQuery),
getUserSpaceIds: jest.fn().mockResolvedValue(opts?.memberSpaceIds ?? []),
};
const pagePermissionRepo = {
hasRestrictedAncestor: jest
.fn()
.mockResolvedValue(opts?.isRestricted ?? false),
// Let everything through page-level permission filtering by default.
filterAccessiblePageIds: jest
.fn()
.mockImplementation(async ({ pageIds }: { pageIds: string[] }) => pageIds),
filterAccessiblePageIds: jest.fn(),
};
const service = new SearchService(
@@ -78,145 +49,81 @@ describe('SearchService.searchPage — query-mode selection', () => {
spaceMemberRepo as any,
pagePermissionRepo as any,
);
return {
service,
db,
builder,
pageRepo,
shareRepo,
spaceMemberRepo,
pagePermissionRepo,
userSpaceIdsQuery,
};
return { service, pageRepo, shareRepo, spaceMemberRepo, pagePermissionRepo };
}
const whereCallFor = (builder: any, column: any) =>
builder.where.mock.calls.find((c: any[]) => c[0] === column);
it('returns {items:[]} for a blank query WITHOUT touching the DB', async () => {
const { service, db } = makeService();
it('returns total:0 for a blank query WITHOUT touching the DB or any repo', async () => {
const { service, shareRepo, spaceMemberRepo } = makeService();
const result = await service.searchPage(
{ query: '' } as any,
{ userId: 'user-1', workspaceId: 'ws-1' },
);
expect(result).toEqual({ items: [] });
// Blank query is rejected before any query builder is constructed.
expect(db.selectFrom).not.toHaveBeenCalled();
expect(result.items).toEqual([]);
expect(result.total).toBe(0);
expect(shareRepo.findById).not.toHaveBeenCalled();
expect(spaceMemberRepo.getUserSpaceIds).not.toHaveBeenCalled();
});
it('scopes to the explicit spaceId branch', async () => {
const { service, builder, db, spaceMemberRepo, shareRepo } = makeService({
rows: [{ id: 'p-1' }],
});
it('only-negation short-circuits with reason "only-negation", never scanning', async () => {
const { service, spaceMemberRepo } = makeService();
const result = await service.searchPage(
{ query: 'plan', spaceId: 'space-42' } as any,
{ query: '-архив' } as any,
{ userId: 'user-1', workspaceId: 'ws-1' },
);
expect(db.selectFrom).toHaveBeenCalledWith('pages');
// The explicit-space branch adds exactly `.where('spaceId', '=', 'space-42')`.
expect(whereCallFor(builder, 'spaceId')).toEqual([
'spaceId',
'=',
'space-42',
]);
// It must NOT fall through to the user-member-spaces or share branch.
expect(spaceMemberRepo.getUserSpaceIdsQuery).not.toHaveBeenCalled();
expect(shareRepo.findById).not.toHaveBeenCalled();
expect(result.items.map((i: any) => i.id)).toEqual(['p-1']);
expect(result.total).toBe(0);
expect(result.query.parsed.reason).toBe('only-negation');
// Never resolves scope (returns before) — no expensive NOT-only scan.
expect(spaceMemberRepo.getUserSpaceIds).not.toHaveBeenCalled();
});
it('scopes an authenticated user WITHOUT spaceId to their member spaces', async () => {
const { service, builder, spaceMemberRepo, userSpaceIdsQuery, shareRepo } =
makeService({ rows: [{ id: 'p-9' }] });
await service.searchPage(
{ query: 'plan' } as any,
{ userId: 'user-7', workspaceId: 'ws-1' },
);
// The user-scoped branch resolves the member-spaces sub-query for that user
// and restricts both spaceId (to that sub-query) and workspaceId.
expect(spaceMemberRepo.getUserSpaceIdsQuery).toHaveBeenCalledWith('user-7');
expect(whereCallFor(builder, 'spaceId')).toEqual([
'spaceId',
'in',
userSpaceIdsQuery,
]);
expect(whereCallFor(builder, 'workspaceId')).toEqual([
'workspaceId',
'=',
'ws-1',
]);
// Authenticated user path must not consult shares.
expect(shareRepo.findById).not.toHaveBeenCalled();
});
it('returns {items:[]} when the share belongs to a DIFFERENT workspace', async () => {
const { service, builder, shareRepo, pagePermissionRepo } = makeService({
share: {
id: 'share-1',
pageId: 'page-1',
workspaceId: 'OTHER-ws',
includeSubPages: false,
},
it('returns empty when the share belongs to a DIFFERENT workspace (no leak)', async () => {
const { service, shareRepo, pagePermissionRepo } = makeService({
share: { id: 's1', pageId: 'p1', workspaceId: 'OTHER', includeSubPages: false },
});
const result = await service.searchPage(
{ query: 'plan', shareId: 'share-1' } as any,
{ query: 'plan', shareId: 's1' } as any,
{ workspaceId: 'ws-1' },
);
expect(shareRepo.findById).toHaveBeenCalledWith('share-1');
expect(result).toEqual({ items: [] });
// Workspace mismatch short-circuits before any restricted-ancestor / id
// scoping or DB execution: no leak across workspaces.
expect(shareRepo.findById).toHaveBeenCalledWith('s1');
expect(result.items).toEqual([]);
// Workspace mismatch short-circuits before restricted-ancestor / enumeration.
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
expect(builder.execute).not.toHaveBeenCalled();
});
it('returns {items:[]} when the shared page has a restricted ancestor', async () => {
const { service, builder, pagePermissionRepo, pageRepo } = makeService({
share: {
id: 'share-1',
pageId: 'page-1',
workspaceId: 'ws-1',
includeSubPages: true,
},
it('returns empty when the shared page has a restricted ancestor', async () => {
const { service, pagePermissionRepo, pageRepo } = makeService({
share: { id: 's1', pageId: 'p1', workspaceId: 'ws-1', includeSubPages: true },
isRestricted: true,
});
const result = await service.searchPage(
{ query: 'plan', shareId: 'share-1' } as any,
{ query: 'plan', shareId: 's1' } as any,
{ workspaceId: 'ws-1' },
);
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(
'page-1',
);
expect(result).toEqual({ items: [] });
// Restricted ancestor must block before page enumeration and DB execution.
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith('p1');
expect(result.items).toEqual([]);
expect(
pageRepo.getPageAndDescendantsExcludingRestricted,
).not.toHaveBeenCalled();
expect(builder.execute).not.toHaveBeenCalled();
});
it('returns {items:[]} with no userId, no spaceId and no shareId', async () => {
const { service, builder, shareRepo } = makeService();
it('returns empty with no userId, no spaceId and no shareId', async () => {
const { service, shareRepo } = makeService();
const result = await service.searchPage(
{ query: 'plan' } as any,
{ workspaceId: 'ws-1' },
);
expect(result).toEqual({ items: [] });
// The catch-all else returns empty without scoping/executing or hitting shares.
expect(result.items).toEqual([]);
expect(shareRepo.findById).not.toHaveBeenCalled();
expect(builder.execute).not.toHaveBeenCalled();
});
it('an authenticated user with NO member spaces gets an empty result', async () => {
const { service, spaceMemberRepo } = makeService({ memberSpaceIds: [] });
const result = await service.searchPage(
{ query: 'plan' } as any,
{ userId: 'user-1', workspaceId: 'ws-1' },
);
expect(spaceMemberRepo.getUserSpaceIds).toHaveBeenCalledWith('user-1');
expect(result.items).toEqual([]);
expect(result.total).toBe(0);
});
});
File diff suppressed because it is too large Load Diff