diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts
index 121d7e5bb0cd..852a674f81e0 100644
--- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts
+++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts
@@ -21,6 +21,18 @@ import { Portlet } from '@utils/portlets';
*/
const OUTCOME_TIMEOUT = 60000;
+/**
+ * The part of the `/api/v1/drive/search` request body the scope tests assert on. Deliberately
+ * partial — the full form is the backend's business — so a new server-side field does not break
+ * these tests, while the two that matter (`text`, `searchScope`) are named.
+ */
+interface DriveSearchPayload {
+ filters: {
+ text?: string;
+ searchScope?: string;
+ };
+}
+
export class ContentDrivePage {
readonly toolbar: Locator;
readonly treeSelector: Locator;
@@ -136,6 +148,89 @@ export class ContentDrivePage {
});
}
+ /**
+ * Runs `action` and returns the JSON request body of the drive search it triggers.
+ *
+ * The wait is armed BEFORE `action` runs — the only order that works — and the predicate also
+ * pins the method and status, so a failing search fails here at the capture rather than
+ * downstream as a missing row.
+ *
+ * `matches` guards against a race the portlet's own startup creates: it can still be settling
+ * its initial listing when this is called, and those searches carry their own (usually empty)
+ * text. Every attempt arms before anything pending can slip past, so `action`'s own request
+ * is never missed — a settling search only costs one round of the loop.
+ */
+ async captureSearchPayload(
+ action: () => Promise,
+ matches?: (payload: DriveSearchPayload) => boolean
+ ): Promise {
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const search = this.page.waitForResponse(
+ (r) =>
+ r.url().includes('/api/v1/drive/search') &&
+ r.request().method() === 'POST' &&
+ r.status() === 200,
+ { timeout: 15000 }
+ );
+
+ if (attempt === 0) {
+ await action();
+ }
+
+ const response = await search;
+ const payload = (await response.request().postDataJSON()) as DriveSearchPayload;
+
+ if (!matches || matches(payload)) {
+ return payload;
+ }
+ }
+
+ throw new Error('no drive search matching the expected payload was captured');
+ }
+
+ /**
+ * Runs `body` and fails if a drive search that matches `matches` was submitted while it did.
+ *
+ * The evidence a negative needs: "re-selecting the active scope must not re-search" (FR-006)
+ * cannot be asserted on copy or on rows that did not appear — only watching the wire can. The
+ * same pattern `expectNothingUploadedWhile` uses for uploads.
+ *
+ * The payloads are inspected rather than the URLs: without `matches` any drive search in the
+ * window is a violation, but with it a caller can name precisely what a violation would look
+ * like. That is what lets the scope test watch a term-carrying window and ignore a leftover
+ * startup search (an empty `text`) instead of draining it with extra round trips first — a
+ * re-search the click caused would carry the same term, so the predicate catches it all the
+ * same while the window stays open. The same evidence pattern, one request fewer to prove it.
+ */
+ async expectNoSearchWhile(
+ body: () => Promise,
+ matches?: (payload: DriveSearchPayload) => boolean
+ ) {
+ const offenders: DriveSearchPayload[] = [];
+ const record = (request: Request) => {
+ if (request.url().includes('/api/v1/drive/search') && request.method() === 'POST') {
+ const payload = request.postDataJSON() as DriveSearchPayload;
+
+ if (!matches || matches(payload)) {
+ offenders.push(payload);
+ }
+ }
+ };
+
+ this.page.on('request', record);
+
+ try {
+ await body();
+ } finally {
+ this.page.off('request', record);
+ }
+
+ expect(
+ offenders,
+ 'a drive search fired, so the scope re-selection was not ignored'
+ ).toEqual([]);
+ }
+
/** Navigates into a folder by clicking its row in the tree. */
async openFolder(name: string) {
await this.treeNodeLabels.filter({ hasText: name }).first().click();
diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-search-scope.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-search-scope.spec.ts
new file mode 100644
index 000000000000..f3a41187e905
--- /dev/null
+++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-search-scope.spec.ts
@@ -0,0 +1,441 @@
+import { ContentDrivePage } from '@pages';
+import { type APIRequestContext } from '@playwright/test';
+import { createFakePayloadTextField } from '@utils/dot-content-types.mock';
+import { Portlet } from '@utils/portlets';
+
+import { ContentDriveSearchScope } from './helpers/content-drive-search';
+import { ContentDriveTree } from './helpers/content-drive-tree';
+
+import { type ContentDriveApiHelpers, expect, test } from '../../fixtures/content-drive.fixture';
+import { createContentlet, deleteContentlets } from '../../requests/contentlets';
+import { createFakeContentType, deleteContentType } from '../../requests/contentType';
+
+/**
+ * Journey: Content Drive search scope — searching by Title or by All Fields (issue #37479)
+ *
+ * The query construction itself is the backend's half, covered by the Java integration suite.
+ * What a browser adds is the wiring no lower suite sees end to end: the control driving real
+ * `/api/v1/drive/search` requests, the rows those requests return, the address-bar round trip,
+ * and the p-listbox toggle that emits `null` when the active option is re-clicked — the exact
+ * kind of synthesized-event gap the keyboard spec exists for.
+ */
+
+/**
+ * What one test's seeding produces. The term is the 8-character per-test suffix behind a `zq`
+ * prefix — rare enough that the only matches in the drive are the rows this test planted.
+ */
+interface SeededWorld {
+ term: string;
+ /** A folder whose NAME contains the term, so folder rows match it (FR-011). */
+ folderName: string;
+ /** The default site's hostname, captured once so the teardown does not re-resolve it. */
+ siteHostname: string;
+ /** A contentlet the term reaches through its TITLE — it is the title's first token. */
+ titleRowTitle: string;
+ /** A contentlet the term reaches ONLY through its body field — its title never contains it. */
+ fieldRowTitle: string;
+ contentletIds: string[];
+ contentTypeId: string;
+}
+
+/**
+ * Seeds one test's search world through the REST API.
+ *
+ * The two contentlets are the pair that tells the scopes apart: in All Fields the term is found
+ * via the catchall (the body token), so both rows come back; in Title scope the prefix match only
+ * reaches the row whose title starts with the term, so the field row disappears. Both sit at the
+ * site root, which is what makes the drive-wide search test readable without a second folder.
+ */
+async function seedSearchWorld(
+ request: APIRequestContext,
+ apiHelpers: ContentDriveApiHelpers,
+ testSuffix: string
+): Promise {
+ const site = await apiHelpers.getDefaultSite();
+ const term = `zq${testSuffix}`;
+ const folderName = `${term}-folder`;
+
+ await apiHelpers.createFolders(site.hostname, [`/${folderName}`]);
+
+ const contentType = await createFakeContentType(request, {
+ name: `ZqSearch${testSuffix}`,
+ fields: [
+ createFakePayloadTextField({ name: 'Title', variable: 'title', sortOrder: 1 }),
+ createFakePayloadTextField({
+ name: 'Search body',
+ variable: 'searchBody',
+ sortOrder: 2,
+ // dotCMS does not index a field unless told to, and the whole point of this
+ // field is to be the one the term reaches through the all-fields catchall. An
+ // unindexed field makes the body row invisible in BOTH scopes, which fails the
+ // test for the wrong reason.
+ indexed: true
+ })
+ ]
+ });
+
+ const titleRow = await createContentlet(request, {
+ contentType: contentType.variable,
+ title: `${term} hero asset`,
+ searchBody: 'a text no search term reaches'
+ });
+ const fieldRow = await createContentlet(request, {
+ contentType: contentType.variable,
+ title: `Herd notes ${testSuffix}`,
+ searchBody: term
+ });
+
+ return {
+ term,
+ folderName,
+ siteHostname: site.hostname,
+ titleRowTitle: `${term} hero asset`,
+ fieldRowTitle: `Herd notes ${testSuffix}`,
+ contentletIds: [titleRow.identifier, fieldRow.identifier],
+ contentTypeId: contentType.id
+ };
+}
+
+test.describe('Content Drive Search Scope', () => {
+ // These tests watch real round trips — each capture waits out the search debounce plus the
+ // backend, and the teardown closes a recorded context and fires the API deletes — all inside
+ // the default 60s budget. On a loaded CI runner (8 workers over a shared Docker backend, per
+ // the pom's own notes) five of them finished their bodies right at the edge and ran out of
+ // budget at teardown, three retries in a row. The doubled budget is not covering a broken
+ // assertion — none fired — it sizes the budget to what watching the wire honestly costs; a
+ // genuinely hung test still fails here, just with room to show which step hung.
+ test.setTimeout(120_000);
+
+ // Set by every test through `seed`, read by the teardown. Deliberately a describe-level
+ // `let` used only for cleanup — each test seeds its own world before asserting on it, so
+ // nothing is shared and `fullyParallel` stays honest.
+ let world: SeededWorld | undefined;
+
+ const seed = async (
+ request: APIRequestContext,
+ apiHelpers: ContentDriveApiHelpers,
+ testSuffix: string
+ ) => {
+ world = await seedSearchWorld(request, apiHelpers, testSuffix);
+
+ return world;
+ };
+
+ test.afterEach(async ({ request, apiHelpers }) => {
+ if (world) {
+ await deleteContentlets(request, world.contentletIds);
+
+ // The site is already known — the seeding fetched it — so the teardown does not spend
+ // a round trip re-resolving it. Every call here rides on the test's own timeout
+ // budget; the leaner this stays, the more of that budget the browser teardown keeps.
+ await apiHelpers.deleteFolders(world.siteHostname, [`/${world.folderName}`]);
+
+ await deleteContentType(request, world.contentTypeId);
+ world = undefined;
+ }
+ });
+
+ test('presents the scope control on the all-fields default with a plain placeholder', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+
+ await search.expectActive('ALL_FIELDS');
+ // The control names itself for assistive technology (FR-023), and the placeholder stays
+ // the shared input's own "Search" — it deliberately does not describe the active scope
+ // (FR-003 as amended).
+ await expect(search.trigger).toHaveAttribute('aria-label', 'Search in');
+ await expect(search.input).toHaveAttribute('placeholder', 'Search');
+ });
+
+ test('marks the active option and explains every option in the panel', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+ await search.open();
+
+ await search.expectOptionMarked('ALL_FIELDS');
+ // FR-022: each option carries the explanation of what it matches. Asserted on a stable
+ // phrase of the English copy, so rewording the rest does not break the test.
+ await search.expectOptionExplained('TITLE', 'name only');
+ await search.expectOptionExplained('ALL_FIELDS', 'any text inside it');
+ });
+
+ test('narrows the results to title matches when title scope is chosen @critical', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+
+ // The default scope sends no scope field at all — omitted, not null (FR-017) — and finds
+ // both rows, the title one and the body-field one.
+ const allFields = await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+ expect(allFields.filters).not.toHaveProperty('searchScope');
+ expect(allFields.filters.text).toBe(seeded.term);
+ await drive.expectListContainsTitle(seeded.titleRowTitle);
+ await drive.expectListContainsTitle(seeded.fieldRowTitle);
+
+ // Choosing a scope re-runs the search immediately (FR-004), carrying the scope (FR-017).
+ // The prefix match reaches the title row but not the body-field row (FR-008).
+ const title = await drive.captureSearchPayload(
+ () => search.choose('TITLE'),
+ (payload) => payload.filters.searchScope === 'TITLE'
+ );
+ expect(title.filters.text).toBe(seeded.term);
+ expect(title.filters.searchScope).toBe('TITLE');
+ await drive.expectListContainsTitle(seeded.titleRowTitle);
+ await expect(drive.listTitles.filter({ hasText: seeded.fieldRowTitle })).toHaveCount(0);
+ });
+
+ test('restores all-fields results when the scope returns to all fields @critical', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+ await search.choose('TITLE');
+
+ // Back to the default: the scope is dropped from the request entirely, never sent as a
+ // literal 'ALL_FIELDS' (FR-017, mirrored by the store deleting the key), and the row only
+ // All Fields can see comes back (FR-009's no-regression promise).
+ const restored = await drive.captureSearchPayload(
+ () => search.choose('ALL_FIELDS'),
+ (payload) => payload.filters.text === seeded.term && !('searchScope' in payload.filters)
+ );
+ expect(restored.filters).not.toHaveProperty('searchScope');
+ await drive.expectListContainsTitle(seeded.fieldRowTitle);
+ });
+
+ test('does not search again when the active scope is re-selected @critical', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+
+ // Type the term the guard protects. The capture waits for THIS search's response — a
+ // predicate that only a term-carrying request satisfies — so the portlet's own startup
+ // searches drain behind it without spending round trips flushing them by hand.
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+
+ // p-listbox is single-select with toggle semantics: re-clicking the active option emits
+ // `null`, which the component must ignore. A re-run would reset the user to page 1 for
+ // nothing, and only watching the wire can prove it did not happen. The guard reads
+ // payloads, not URLs: a re-search the click caused would carry this same term, while a
+ // leftover startup search (empty `text`) is not a violation — the predicate keeps the two
+ // apart, so the proof stays exact without any settling choreography.
+ await drive.expectNoSearchWhile(
+ async () => {
+ await search.open();
+ await search.option('ALL_FIELDS').click();
+ // A re-search would be a state change → store effect → request, all synchronous
+ // with the click itself — unlike the typing path, which carries the debounce. The
+ // panel closing is the deterministic signal that the click's consequences ran.
+ await expect(search.panel).toBeHidden();
+ },
+ (payload) => payload.filters.text === seeded.term
+ );
+ });
+
+ test('searches drive-wide and leaves the selected folder behind', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const tree = new ContentDriveTree(adminPage);
+
+ await drive.goTo();
+
+ // Narrow the listing to the (empty) seeded folder first, so what appears next cannot be
+ // explained by the folder scope: both contentlets live at the site root, outside it.
+ await tree.selectFolder(seeded.folderName);
+
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+
+ await drive.expectListContainsTitle(seeded.titleRowTitle);
+ await drive.expectListContainsTitle(seeded.fieldRowTitle);
+ });
+
+ test('returns the scope to all fields when all filters are cleared @critical', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+ await search.choose('TITLE');
+
+ const clearAll = adminPage.getByTestId('clear-all-filters');
+ await expect(clearAll).toBeVisible();
+ await clearAll.click();
+
+ // The scope qualifies the term; with the filters gone it must be gone too, or the trigger
+ // would claim a scope the listing is not using (FR-020).
+ await search.expectActive('ALL_FIELDS');
+ });
+
+ test('resolves a deep link to the scope it names and degrades an unknown one @critical', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ // The address carries the scope beside the other filters (FR-014), and the drive runs the
+ // filtered search on load — no typing needed.
+ await adminPage.goto(
+ `${Portlet.ContentDrive}?filters=${encodeURIComponent(
+ `title:${seeded.term};searchScope:TITLE`
+ )}`
+ );
+ await expect(drive.toolbar).toBeVisible({ timeout: 20000 });
+
+ await search.expectActive('TITLE');
+ await drive.expectListContainsTitle(seeded.titleRowTitle);
+ await expect(drive.listTitles.filter({ hasText: seeded.fieldRowTitle })).toHaveCount(0);
+
+ // A stale or hand-edited scope must degrade to the default, never 400 the listing
+ // (FR-015): the unknown value is dropped and the term keeps its All Fields breadth.
+ await adminPage.goto(
+ `${Portlet.ContentDrive}?filters=${encodeURIComponent(
+ `title:${seeded.term};searchScope:WAT`
+ )}`
+ );
+ await expect(drive.toolbar).toBeVisible({ timeout: 20000 });
+
+ await search.expectActive('ALL_FIELDS');
+ await drive.expectListContainsTitle(seeded.fieldRowTitle);
+ });
+
+ test('starts a clean visit on all fields after a scope was changed', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+ await search.choose('TITLE');
+
+ // The scope is filter state in the address, not a per-user preference (FR-016): a clean
+ // entry — no query string — starts over on the default. A second goto alone would only
+ // change the hash inside the same Angular document: no reload, no startup requests, and
+ // goTo's own waits would stall on responses that never fire. Leaving the app first makes
+ // the return a real page load — a genuinely clean visit, which is the thing under test.
+ await adminPage.goto('about:blank');
+ await drive.goTo();
+
+ await search.expectActive('ALL_FIELDS');
+ });
+
+ test('searches a term made of query syntax without breaking the listing', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+
+ await drive.goTo();
+
+ // The term is treated as literal text (FR-027): the capture's predicate already proves
+ // the request answered 200 — an unescaped term would have failed the query before any
+ // results could render (FR-029), surfacing as the search error toast and a stale grid.
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill('(a+b)'),
+ (payload) => payload.filters.text === '(a+b)'
+ );
+
+ await expect(adminPage.locator('.p-toast-message')).toHaveCount(0);
+ await expect(drive.listTitles).toHaveCount(0);
+ });
+
+ test('matches folders by name identically in both scopes', async ({
+ adminPage,
+ apiHelpers,
+ request,
+ testSuffix
+ }) => {
+ const seeded = await seed(request, apiHelpers, testSuffix);
+ const drive = new ContentDrivePage(adminPage);
+ const search = new ContentDriveSearchScope(adminPage);
+
+ await drive.goTo();
+
+ await drive.captureSearchPayload(
+ () => drive.searchField.fill(seeded.term),
+ (payload) => payload.filters.text === seeded.term
+ );
+ await drive.expectListContainsTitle(seeded.folderName);
+
+ // Folder names match the term as a substring no matter the scope (FR-011): the scope
+ // narrows contentlets, not the structure the user navigates through.
+ await search.choose('TITLE');
+ await drive.expectListContainsTitle(seeded.folderName);
+ });
+});
diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-search.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-search.ts
new file mode 100644
index 000000000000..e75f94771a66
--- /dev/null
+++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-search.ts
@@ -0,0 +1,90 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+/**
+ * The two values the scope control offers, mirroring `DotContentDriveSearchScope` on the UI side.
+ * They are also the `data-testid` suffixes of the panel's options.
+ */
+export type DriveSearchScope = 'TITLE' | 'ALL_FIELDS';
+
+/**
+ * The English trigger label for each scope. The instance runs the default locale, and the label is
+ * how a scope change is observed without opening the panel, so the strings live here rather than
+ * being inlined at every call site. Source of truth: `content-drive.search.scope.*` in
+ * `Language.properties`.
+ */
+const SCOPE_LABEL: Record = {
+ TITLE: 'Title',
+ ALL_FIELDS: 'All Fields'
+};
+
+/**
+ * Locator wrapper for the Content Drive search scope control — the trigger in the toolbar addon
+ * and the popover panel it opens (issue #37479).
+ *
+ * Every `data-testid` lives on `dot-content-drive-search-input` itself, so this stays a locator
+ * question: no behavior is re-implemented here, and a testid change fails loudly at the locator.
+ */
+export class ContentDriveSearchScope {
+ readonly trigger: Locator;
+ readonly activeLabel: Locator;
+ readonly panel: Locator;
+ readonly input: Locator;
+
+ constructor(private page: Page) {
+ this.trigger = page.getByTestId('search-scope-trigger');
+ this.activeLabel = page.getByTestId('search-scope-active');
+ this.panel = page.getByTestId('search-scope-panel');
+ this.input = page.getByTestId('search-input-field');
+ }
+
+ /** An option's inner span, reached through its value-named test id. */
+ option(scope: DriveSearchScope): Locator {
+ return this.panel.getByTestId(`search-scope-option-${scope}`);
+ }
+
+ /** Opens the panel and waits for it to render. */
+ async open() {
+ await this.trigger.click();
+ await expect(this.panel).toBeVisible({ timeout: 5000 });
+ }
+
+ /**
+ * Picks a scope through the panel and waits for the choice to land: the panel closes and the
+ * trigger reads the chosen scope. Choosing re-runs the search (FR-004), so a test that needs
+ * the request itself arms a capture around this — see
+ * `ContentDrivePage.captureSearchPayload`.
+ */
+ async choose(scope: DriveSearchScope) {
+ await this.open();
+ await this.option(scope).click();
+ await expect(this.panel).toBeHidden({ timeout: 5000 });
+ await this.expectActive(scope);
+ }
+
+ /** The trigger reads the active scope as its label (FR-002). */
+ async expectActive(scope: DriveSearchScope) {
+ await expect(this.activeLabel).toHaveText(SCOPE_LABEL[scope]);
+ }
+
+ /** The panel marks the active option, so the user can tell which one is on. */
+ async expectOptionMarked(scope: DriveSearchScope) {
+ // The option row is reached by role and label — PrimeNG renders `role="option"` with the
+ // option's label as its accessible name — and the selection state on `aria-selected`.
+ await expect(this.optionRow(scope)).toHaveAttribute('aria-selected', 'true');
+ }
+
+ /**
+ * The option's explanation comes up as a tooltip on hover (FR-022). Asserted on a stable
+ * phrase of the English copy rather than the whole string, so rewording the rest of the
+ * sentence does not break the test.
+ */
+ async expectOptionExplained(scope: DriveSearchScope, phrase: string) {
+ await this.option(scope).hover();
+ await expect(this.page.locator('.p-tooltip')).toContainText(phrase, { timeout: 5000 });
+ }
+
+ /** The listbox option element that carries the option's span. */
+ private optionRow(scope: DriveSearchScope): Locator {
+ return this.panel.getByRole('option', { name: SCOPE_LABEL[scope] });
+ }
+}
diff --git a/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts b/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts
index bc8f5b9740e4..3ba982a6fc9f 100644
--- a/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts
+++ b/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts
@@ -189,6 +189,19 @@ export interface DotContentDriveQueryFilters {
* Text to search for.
*/
text: string;
+
+ /**
+ * Which fields {@link text} is matched against.
+ *
+ * Sits here rather than at the top level because it qualifies `text` and means nothing without
+ * it — the same reason {@link filterFolders} lives here. Omit it for the historical behaviour:
+ * an absent scope is processed exactly as it was before the field existed, which is what keeps
+ * the AssetPicker unaffected.
+ *
+ * Not to be confused with a *browse* scope, which says where you are browsing rather than which
+ * fields a search reads.
+ */
+ searchScope?: 'TITLE' | 'ALL_FIELDS';
}
/**
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.html
new file mode 100644
index 000000000000..a05adc23666b
--- /dev/null
+++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts
index c4cd5134b188..33b10773a88e 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts
+++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts
@@ -1,4 +1,5 @@
import {
+ byTestId,
createComponentFactory,
mockProvider,
Spectator,
@@ -8,6 +9,7 @@ import { vi } from 'vitest';
import { By } from '@angular/platform-browser';
+import { Tooltip } from 'primeng/tooltip';
import { ZIndexUtils } from 'primeng/utils';
import { DotMessageService } from '@dotcms/data-access';
@@ -28,7 +30,8 @@ describe('DotContentDriveSearchInputComponent', () => {
mockProvider(DotContentDriveStore, {
getFilterValue: vi.fn().mockReturnValue(undefined),
setGlobalSearch: vi.fn(),
- selectRootNode: vi.fn()
+ selectRootNode: vi.fn(),
+ setSearchScope: vi.fn()
}),
{
provide: DotMessageService,
@@ -90,6 +93,147 @@ describe('DotContentDriveSearchInputComponent', () => {
// The claim lives here rather than in the shell because this component is the one holding the
// search box; the shell would have to reach three levels down to find it.
+ describe('search scope', () => {
+ // Absent from the filters means the default. The key is deliberately NOT `title` — that
+ // holds the search TERM, and a scope whose value is 'TITLE' beside it would be a collision.
+ const withScope = (scope?: string) =>
+ store.getFilterValue.mockImplementation((key: string) =>
+ key === 'searchScope' ? scope : undefined
+ );
+
+ it('should render the scope control next to the search input', () => {
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('search-scope-trigger'))).toBeTruthy();
+ expect(searchInput()).toBeTruthy();
+ });
+
+ it('should show the active scope on the trigger', () => {
+ withScope('TITLE');
+ spectator.detectChanges();
+
+ expect(spectator.component['$activeScopeLabel']()).toBe(
+ 'content-drive.search.scope.title'
+ );
+ });
+
+ it('should start on All Fields when nothing is stored', () => {
+ withScope(undefined);
+ spectator.detectChanges();
+
+ expect(spectator.component['$searchScope']()).toBe('ALL_FIELDS');
+ });
+
+ it('should read the stored scope when one is set', () => {
+ withScope('TITLE');
+ spectator.detectChanges();
+
+ expect(spectator.component['$searchScope']()).toBe('TITLE');
+ });
+
+ // The placeholder deliberately does NOT describe the active scope — it is always the
+ // shared component's own default ("Search"), so no [placeholder] binding is passed at all.
+ // Regression guard for both directions: scope changes must not reintroduce one.
+ it('should leave the input on its default placeholder in Title scope', () => {
+ withScope('TITLE');
+ spectator.detectChanges();
+
+ expect(searchInput().componentInstance.$placeholder()).toBe('search');
+ });
+
+ it('should leave the input on its default placeholder in All Fields scope', () => {
+ withScope(undefined);
+ spectator.detectChanges();
+
+ expect(searchInput().componentInstance.$placeholder()).toBe('search');
+ });
+
+ it('should record a newly chosen scope', () => {
+ withScope(undefined);
+ spectator.detectChanges();
+
+ spectator.component['onScopeChange']('TITLE');
+
+ expect(store.setSearchScope).toHaveBeenCalledWith('TITLE');
+ });
+
+ it('should ignore re-selecting the scope that is already active', () => {
+ withScope('TITLE');
+ spectator.detectChanges();
+
+ spectator.component['onScopeChange']('TITLE');
+
+ // Re-running cannot change the results, and it would reset the user to page 1.
+ expect(store.setSearchScope).not.toHaveBeenCalled();
+ });
+
+ // p-listbox is single-select with metaKeySelection=false, so re-clicking the already
+ // active option TOGGLES it and emits `null` via (ngModelChange) instead of the option's
+ // value. Before the null guard, that null slipped past the "already active" check (`null
+ // !== 'TITLE'`) and reached the store as a real scope change — silently dropping the user
+ // back to All Fields and marking a clean drive as filtered (issue #37554 review).
+ it('should ignore a null emission from re-clicking the active scope in the listbox', () => {
+ withScope('TITLE');
+ spectator.detectChanges();
+
+ spectator.component['onScopeChange'](null);
+
+ expect(store.setSearchScope).not.toHaveBeenCalled();
+ });
+
+ it('should name the control for assistive technology', () => {
+ spectator.detectChanges();
+
+ expect(
+ spectator.query(byTestId('search-scope-trigger'))?.getAttribute('aria-label')
+ ).toBeTruthy();
+ });
+
+ // Lara centers a button's content, so label and chevron recentered as one group every time
+ // the active scope changed — "Title" and "All Fields" rendered at different offsets.
+ // `TRIGGER_PT` pins them to the button's edges instead; asserted through the rendered
+ // inline style, so a lost PT slot fails here rather than in QA.
+ it('should keep the trigger label and chevron in place when the scope changes', () => {
+ spectator.detectChanges();
+
+ const trigger = spectator.query(byTestId('search-scope-trigger')) as HTMLElement;
+
+ expect(trigger.style.justifyContent).toBe('space-between');
+ });
+
+ // The gray of `p-button-secondary` must go white through the PT inline style, not a
+ // Tailwind class: PrimeNG's styles are appended to the head after the Tailwind stylesheet,
+ // so `bg-white` loses the cascade war at equal specificity. The value is the input's own
+ // token variable, so both halves of the field stay the same white in any theme.
+ it('should paint the trigger with the field background', () => {
+ spectator.detectChanges();
+
+ const trigger = spectator.query(byTestId('search-scope-trigger')) as HTMLElement;
+
+ expect(trigger.style.background).toBe('var(--p-inputtext-background)');
+ });
+
+ it('should offer a distinct explanation for each option in the panel', () => {
+ spectator.detectChanges();
+
+ // Two labels do not carry the distinction between "the item's name" and "anything
+ // written inside it", and the control is new. The explanation lives on each option
+ // row in the panel, not on the trigger — asserted through the directive instances
+ // rather than an ng-reflect attribute, which Angular only emits in development mode.
+ spectator.click(byTestId('search-scope-trigger'));
+ spectator.detectChanges();
+
+ const tooltips = spectator.queryAll(Tooltip);
+
+ expect(tooltips.length).toBe(2);
+ expect(tooltips.every((tooltip) => !!tooltip.content)).toBe(true);
+
+ const contents = tooltips.map((tooltip) => tooltip.content);
+
+ expect(new Set(contents).size).toBe(2);
+ });
+ });
+
describe('search shortcut', () => {
/** Dispatches from `target` (the document unless a field is given), the way a browser would. */
const press = (init: KeyboardEventInit, target: EventTarget = document): KeyboardEvent => {
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts
index 2aff99ad324c..8590767391df 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts
+++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts
@@ -6,14 +6,32 @@ import {
OnDestroy,
viewChild
} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ButtonModule } from 'primeng/button';
+import { InputGroupModule } from 'primeng/inputgroup';
+import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
+import { ListboxModule } from 'primeng/listbox';
+import { PopoverModule } from 'primeng/popover';
+import { TooltipModule } from 'primeng/tooltip';
+
+import { DotMessageService } from '@dotcms/data-access';
import {
+ CHIP_FILTER_LISTBOX_PT,
+ CHIP_FILTER_POPOVER_PT,
+ CHIP_FILTER_SCROLL_HEIGHT,
DotKeyboardShortcutService,
DotKeyboardShortcutUnregister,
+ DotMessagePipe,
DotSearchInputComponent,
hasOverlayAbove
} from '@dotcms/ui';
+import { DEFAULT_SEARCH_SCOPE, SEARCH_SCOPE_FILTER_KEY } from '../../../../shared/constants';
+import {
+ DOT_CONTENT_DRIVE_SEARCH_SCOPE,
+ DotContentDriveSearchScope
+} from '../../../../shared/models';
import { DotContentDriveStore } from '../../../../store/dot-content-drive.store';
/**
@@ -23,17 +41,25 @@ import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'
*/
@Component({
selector: 'dot-content-drive-search-input',
- template: `
-
-
- `,
+ templateUrl: './dot-content-drive-search-input.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [DotSearchInputComponent],
+ imports: [
+ DotSearchInputComponent,
+ ButtonModule,
+ InputGroupModule,
+ InputGroupAddonModule,
+ ListboxModule,
+ PopoverModule,
+ TooltipModule,
+ DotMessagePipe,
+ FormsModule
+ ],
host: { class: 'w-full' }
})
export class DotContentDriveSearchInputComponent implements OnDestroy {
readonly #store = inject(DotContentDriveStore);
readonly #shortcuts = inject(DotKeyboardShortcutService);
+ readonly #messageService = inject(DotMessageService);
/** Withdrawals for the claims made in the constructor, released in {@link ngOnDestroy}. */
#withdrawShortcuts: DotKeyboardShortcutUnregister[] = [];
@@ -47,6 +73,108 @@ export class DotContentDriveSearchInputComponent implements OnDestroy {
() => (this.#store.getFilterValue('title') as string) ?? ''
);
+ /**
+ * The two options, in the order they read best: the narrow one first, because it is the choice
+ * the user is here to make. The wide one is called "All Fields" rather than "All Content" — it
+ * widens which FIELDS are read, not which content is searched, and Content Drive is separately
+ * gaining a browse scope where "All" genuinely means all content.
+ *
+ * `label` is resolved here, eagerly, rather than left as a raw i18n key for the item template
+ * to pipe through `| dm`. `p-listbox` puts its own `[attr.aria-label]` on each option element
+ * from `getOptionLabel(option)`, which falls back to the raw `option.label` — it does not go
+ * through this component's template, so a piped label left a screen reader announcing the key
+ * itself (e.g. "content-drive.search.scope.title") instead of the translated text.
+ */
+ protected readonly scopeOptions = [
+ {
+ label: this.#messageService.get('content-drive.search.scope.title'),
+ help: 'content-drive.search.scope.title.help',
+ value: DOT_CONTENT_DRIVE_SEARCH_SCOPE.TITLE
+ },
+ {
+ label: this.#messageService.get('content-drive.search.scope.all-fields'),
+ help: 'content-drive.search.scope.all-fields.help',
+ value: DOT_CONTENT_DRIVE_SEARCH_SCOPE.ALL_FIELDS
+ }
+ ];
+
+ /**
+ * The same popover/listbox pass-through every other Content Drive filter dropdown already uses
+ * (`dot-content-drive-workflow-filter`, `dot-status-filter`, …), so this panel matches the rest
+ * of the toolbar instead of introducing its own styling.
+ */
+ protected readonly POPOVER_PT = CHIP_FILTER_POPOVER_PT;
+ protected readonly LISTBOX_PT = CHIP_FILTER_LISTBOX_PT;
+ protected readonly SCROLL_HEIGHT = CHIP_FILTER_SCROLL_HEIGHT;
+
+ /** Absent from the filters means the default — the scope is only stored when it differs. */
+ protected readonly $searchScope = computed(
+ () =>
+ (this.#store.getFilterValue(SEARCH_SCOPE_FILTER_KEY) as DotContentDriveSearchScope) ??
+ DEFAULT_SEARCH_SCOPE
+ );
+
+ /**
+ * The trigger shows the active scope, as the mock does. Already-translated text — see the
+ * comment on `scopeOptions` for why the label is resolved there rather than piped through
+ * `| dm` in the template.
+ */
+ protected readonly $activeScopeLabel = computed(
+ () =>
+ this.scopeOptions.find((option) => option.value === this.$searchScope())?.label ??
+ this.#messageService.get('content-drive.search.scope.all-fields')
+ );
+
+ /**
+ * The trigger's own border removed and its background set to the field's white, both via PT
+ * rather than Tailwind classes, and its content pinned to the edges so the active scope reads
+ * from the same place no matter which scope is active.
+ *
+ * `pButtonPT`'s `root.style` is consumed through `[style]`/`[class]` HOST BINDINGS on the
+ * directive's own host element (see `Bind`, the directive backing this), which Angular applies
+ * the same way any `[style]` binding is — as a real inline style. That wins the cascade over
+ * PrimeNG's own injected `.p-button-secondary` rule unconditionally, the same guarantee
+ * `!important` gives, without reaching for it. A plain `bg-white` class cannot: PrimeNG's
+ * styles are appended to the head at runtime, after the Tailwind stylesheet, so at equal
+ * specificity the injected rule wins — which is why the border went through PT first and the
+ * background follows it.
+ *
+ * The background value is the same design token the neighboring input paints with —
+ * `inputtext.background`, read as the CSS variable PrimeNG emits for every token — so the two
+ * halves of the field stay the same white in any theme, including dark mode, and a restyling
+ * of the input re-whites the trigger for free. As a side effect the hover recolor
+ * `.p-button-secondary` would apply is also beaten by the inline style, which is what a
+ * dropdown trigger wants: the surface does not change under the pointer, the way `p-select`
+ * behaves.
+ *
+ * The last entry exists because Lara centers a button's content (`justify-content: center`,
+ * hardcoded in the injected stylesheet — no design token exposes it). Centered, the label and
+ * the chevron are one group that recenters itself as its width changes, so "Title" and
+ * "All Fields" rendered with both at different offsets. `space-between` — the layout PrimeNG's
+ * own `p-select` trigger uses — parks the label on the left edge and the chevron on the right
+ * edge of the fixed-width button, so switching scopes moves neither.
+ */
+ protected readonly TRIGGER_PT = {
+ root: {
+ style: {
+ border: 'none',
+ background: 'var(--p-inputtext-background)',
+ justifyContent: 'space-between'
+ }
+ }
+ };
+
+ /**
+ * Squares off the search input's connecting edge — the side that meets the scope addon. This
+ * component's own host sits between `p-inputgroup` and the real ``, breaking PrimeNG's
+ * structural CSS, so the override rides the design token through `inputDt` instead of
+ * competing for specificity (see the template comment). Hoisted like `TRIGGER_PT`: an inline
+ * object literal would be recreated on every change detection cycle.
+ */
+ protected readonly INPUT_DT = {
+ border: { radius: '{form.field.border.radius} 0 0 {form.field.border.radius}' }
+ };
+
/**
* Claims the search shortcuts for as long as this box is on screen.
*
@@ -116,4 +244,28 @@ export class DotContentDriveSearchInputComponent implements OnDestroy {
this.#store.setGlobalSearch(term);
this.#store.selectRootNode();
}
+
+ /**
+ * Records the new scope and lets the store re-run the search.
+ *
+ * Re-selecting the scope that is already active is ignored: the results cannot change, and
+ * `patchFilters` would reset the user to page 1 for nothing.
+ *
+ * The `null` guard is load-bearing, not defensive filler. `p-listbox` is single-select with
+ * `metaKeySelection` at its default of `false`, which makes it a TOGGLE: re-clicking the
+ * option that is already selected emits `null` instead of the option's value
+ * (`onOptionSelectSingle` in `primeng/listbox`). Without the guard that `null` reaches
+ * `setSearchScope`, which only special-cases the real default value — `null` is neither that
+ * nor the current scope, so it gets written into the filters as `searchScope: null`. That
+ * silently flips the active search to All Fields and lights up "Clear all" on a drive with
+ * nothing filtered, and the bad value can then never be reselected away because
+ * `$searchScope()` already reads back as a real scope.
+ */
+ protected onScopeChange(scope: DotContentDriveSearchScope | null): void {
+ if (scope == null || scope === this.$searchScope()) {
+ return;
+ }
+
+ this.#store.setSearchScope(scope);
+ }
}
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html
index 957e4812ddee..2011fdf52b50 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html
+++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html
@@ -37,9 +37,29 @@
(uploadFiles)="onRequestUpload($event)"
(dragEnter)="onDropzoneDragEnter()"
class="col-start-2 row-start-3 overflow-auto">
-
+
+ @if ($searchFailed()) {
+
+
+
+ }
+
{
expect(location.go).not.toHaveBeenCalled();
});
+ // The search scope rides the generic filter machinery rather than a mechanism of its own,
+ // which is what makes it survive reload, Back/Forward and a shared link for free. These pin
+ // that it actually reaches the address, and that the default never does.
+ it('should carry a non-default search scope into the address', () => {
+ store.isTreeExpanded.mockReturnValue(false);
+ store.path.mockReturnValue('/');
+ filtersSignal.set({ title: 'pricing', searchScope: 'TITLE' });
+ spectator.detectChanges();
+
+ expect(location.replaceState).toHaveBeenCalledWith(
+ expect.stringContaining('searchScope%3ATITLE')
+ );
+ });
+
+ it('should keep the default search scope out of the address', () => {
+ store.isTreeExpanded.mockReturnValue(false);
+ store.path.mockReturnValue('/');
+ // The store removes the key rather than storing the default, so the address stays as
+ // clean as it would have been had the control never been touched.
+ filtersSignal.set({ title: 'pricing' });
+ spectator.detectChanges();
+
+ expect(location.replaceState).not.toHaveBeenCalledWith(
+ expect.stringContaining('searchScope')
+ );
+ });
+
it('pushes a history entry when the user navigates to a different folder', () => {
// Folder navigation is a real user action, so Back must step back up the tree. Only the
// automatic filter seed is denied an entry.
@@ -1581,6 +1608,57 @@ describe('DotContentDriveShellComponent', () => {
});
});
+ // A search that failed to run must not be presented as a search that found nothing. The two
+ // were the same empty grid before issue #37532, which is how a customer came to believe content
+ // they were looking at had vanished.
+ //
+ // The status is set per test and restored afterwards: it is shared across this file, and
+ // leaving it on ERROR hides the listing from every test that follows.
+ describe('when a search fails to run', () => {
+ afterEach(() => {
+ statusSignal.set(DotContentDriveStatus.LOADING);
+ spectator.detectChanges();
+ });
+
+ const failSearch = () => {
+ statusSignal.set(DotContentDriveStatus.ERROR);
+ spectator.detectChanges();
+ };
+
+ it('should show an error state instead of the listing', () => {
+ failSearch();
+
+ expect(spectator.query(byTestId('search-error-state'))).toBeTruthy();
+ });
+
+ it('should keep the error visible alongside the listing it explains', () => {
+ failSearch();
+
+ // The banner sits above the grid rather than replacing it, so the user sees WHY the
+ // results are incomplete instead of an unexplained empty table.
+ expect(spectator.query(byTestId('search-error-state'))).toBeTruthy();
+ expect(spectator.query('dot-folder-list-view')).toBeTruthy();
+ });
+
+ it('should announce the failure to assistive technology', () => {
+ failSearch();
+
+ expect(spectator.query(byTestId('search-error-state'))?.getAttribute('role')).toBe(
+ 'alert'
+ );
+ });
+
+ it('should re-run the search when the user retries', () => {
+ failSearch();
+
+ // The testid lands on the host; the clickable element is the
+ *
+ *
This class exists to keep that boundary honest: if someone edits
+ * {@link GlobalSearchAttributeStrategy} later — to fix this same defect here, say — these
+ * assertions fail and make the Search-portlet/Relationships-dialog impact visible in review rather
+ * than discovered by a customer.
+ *
+ *
+ *
Invariant cases — a term with no reserved character. Byte-identical query, and
+ * should stay that way indefinitely.
+ *
Carve-out cases — a term with a reserved character or consecutive separators,
+ * pinning the known-malformed output described above. These are expected to keep failing to
+ * parse in the Search portlet and Relationships dialog; that is the tracked, accepted
+ * trade-off, not a regression.
+ *
+ *
+ * @see #37532
+ */
+public class GlobalSearchAttributeStrategyBaselineTest {
+
+ private static String query(final String value) {
+ return new GlobalSearchAttributeStrategy().generateQuery(
+ new FieldContext.Builder().withFieldName("title").withFieldValue(value).build());
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Invariant: no reserved characters. Must stay byte-identical indefinitely.
+ // ---------------------------------------------------------------------------------------
+
+ /** A single plain word — the most common search there is. */
+ @Test
+ public void baseline_plainWord() {
+ assertEquals(
+ "+(catchall:pricing*^10 OR title_dotraw:*pricing*^2) "
+ + "title:'pricing'^15 "
+ + "title:pricing*",
+ query("pricing"));
+ }
+
+ /** Multiple words: a space is not a Lucene reserved character, so this is invariant too. */
+ @Test
+ public void baseline_multipleWords() {
+ assertEquals(
+ "+(catchall:hello world*^10 OR title_dotraw:*hello world*^2) "
+ + "title:'hello world'^15 "
+ + "title:hello^5 title:world^5 "
+ + "title:hello world*",
+ query("hello world"));
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Carve-out: reserved characters. These record the known-malformed output issue #37532
+ // reports for the Search portlet / Relationships dialog, deliberately left as-is here.
+ // ---------------------------------------------------------------------------------------
+
+ /**
+ * A hyphen is reserved. The asymmetry is the whole defect: the mandatory gate — the clause
+ * that decides whether a document matches at all — carries the RAW {@code angular-cms}, and
+ * only the trailing clause is escaped to {@code angular\-cms}. Elasticsearch cannot parse a
+ * bare hyphen in {@code query_string} position, so the gate fails and the search returns
+ * nothing for a title that exists.
+ */
+ @Test
+ public void baseline_reservedCharacter_hyphen_onlyTrailingClauseIsEscaped() {
+ assertEquals(
+ "+(catchall:angular-cms*^10 OR title_dotraw:*angular-cms*^2) "
+ + "title:'angular-cms'^15 "
+ + "title:angular\\-cms*",
+ query("angular-cms"));
+ }
+
+ /**
+ * A forward slash is reserved by the {@code query_string} syntax but is absent from this
+ * strategy's private escape set, so it is not escaped anywhere — not even in the
+ * trailing clause. This is #37532's fifth acceptance criterion, reproduced.
+ */
+ @Test
+ public void baseline_forwardSlash_isEscapedNowhere() {
+ assertEquals(
+ "+(catchall:a/b*^10 OR title_dotraw:*a/b*^2) "
+ + "title:'a/b'^15 "
+ + "title:a/b*",
+ query("a/b"));
+ }
+
+ /**
+ * Consecutive separators split into an empty token, emitting a term-less {@code title:^5}
+ * clause that cannot parse (the FR-028 defect, also reproduced here).
+ */
+ @Test
+ public void baseline_consecutiveSpaces_emitsTermlessClause() {
+ assertEquals(
+ "+(catchall:a b*^10 OR title_dotraw:*a b*^2) "
+ + "title:'a b'^15 "
+ + "title:a^5 title:^5 title:b^5 "
+ + "title:a b*",
+ query("a b"));
+ }
+}
diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java
index 35f58f427945..0a1f11bd06b0 100644
--- a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java
+++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java
@@ -11,6 +11,8 @@
import com.dotcms.rest.api.v1.drive.ContentDriveFieldFilterTest;
import com.dotcms.rest.api.v1.drive.ContentDriveHelperContentletAPIComparisonTest;
import com.dotcms.rest.api.v1.drive.ContentDriveKeywordSearchTest;
+import com.dotcms.rest.api.v1.drive.ContentDriveLiteralTextSearchTest;
+import com.dotcms.rest.api.v1.drive.ContentDriveSearchScopeTest;
import com.dotcms.rest.api.v1.drive.ContentDriveLinksTest;
import com.dotcms.rest.api.v1.drive.ContentDriveWorkflowArchiveStepTest;
import com.dotcms.rest.api.v1.drive.ContentDriveWorkflowFilterTest;
@@ -88,6 +90,8 @@
ContentDriveFieldFilterTest.class,
ContentDriveHelperContentletAPIComparisonTest.class,
ContentDriveKeywordSearchTest.class,
+ ContentDriveLiteralTextSearchTest.class,
+ ContentDriveSearchScopeTest.class,
ContentDriveLinksTest.class,
ContentDriveWorkflowArchiveStepTest.class,
ContentDriveWorkflowFilterTest.class,
diff --git a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java
index 48d17a3a8f87..a5ce40909ab5 100644
--- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java
@@ -1382,13 +1382,21 @@ public void test_buildBaseESQuery_withDifferentFilterCombinations() {
assertTrue("Should contain AND operator", result.contains(" AND "));
}
- // Test Case 5: Filter with special characters
+ // Test Case 5: Filter with special characters.
+ //
+ // CHANGED by issue #37532 (FR-027, SC-002's enumerated carve-out). "&" is a Lucene
+ // query_string reserved character; before that fix it survived into the query unescaped —
+ // exactly the defect the issue reports, just with a different symbol than the customer's
+ // ":"/"("/"/". It must now appear backslash-escaped rather than raw, which is what "handles"
+ // it means here: the term is matched literally instead of altering the query's structure.
BrowserQuery querySpecialChars = BrowserQuery.builder()
.withFilter("test & special")
.build();
result = browserAPIImpl.buildBaseESQuery(querySpecialChars);
assertNotNull("Result should not be null", result);
- assertTrue("Should handle special characters in filter", result.contains("test & special"));
+ assertTrue("Should handle special characters in filter", result.contains("test \\& special"));
+ assertFalse("The raw, unescaped '&' must not survive into the query",
+ result.contains("test & special"));
// Test Case 6: Empty string filter
BrowserQuery queryEmptyFilter = BrowserQuery.builder()
@@ -2650,6 +2658,73 @@ private static File fileNamed(final String name) throws IOException {
return file;
}
+ /**
+ * Isolation test for issue #37479 / #37532 (FR-024, SC-008): the search scope and the
+ * literal-text escaping fix live entirely inside {@link BrowserAPIImpl}'s Elasticsearch text
+ * branch, which is reachable only when {@link BrowserQuery#useElasticsearchFiltering} is set.
+ * {@link com.dotcms.rest.api.v1.drive.ContentDriveHelper} is the only caller in the codebase
+ * that ever sets it (verified by grep against {@code main} at spec time); every other consumer
+ * of {@link BrowserAPI#getFolderContentList(BrowserQuery)} — the assets REST API
+ * ({@code WebAssetHelper}), the legacy admin browser ({@code BrowserAjax}) and the Velocity
+ * viewtool ({@code DotCMSMacroWebAPI}) — builds a {@link BrowserQuery} without it and is
+ * therefore routed to the SQL {@code ILIKE} path this feature never touches.
+ *
+ *
This test does not call those three classes directly (they carry their own request/servlet
+ * dependencies that do not belong in a {@code BrowserAPI} test). It instead reproduces the one
+ * property that makes them safe: a {@link BrowserQuery} built the way they build it — a text
+ * filter set, {@code useElasticsearchFiltering} left at its default {@code false} — must return
+ * a result identical to what the same query returned before this feature existed. A term
+ * carrying Lucene reserved characters is deliberately used as the probe: it is exactly the input
+ * class this feature changes behaviour for on the ES path, so an unchanged result here is the
+ * strongest available evidence that the SQL path was never touched.
+ */
+ @Test
+ public void searchScopeAndEscapingFix_doNotReachTheSqlFilterPath_usedByEveryOtherCaller()
+ throws DotDataException, DotSecurityException, IOException {
+ final Host site = new SiteDataGen().nextPersisted();
+ final Folder folder = new FolderDataGen().site(site).nextPersisted();
+
+ // A reserved-character term the ES-side fix specifically targets (#37532): if the SQL path
+ // were somehow affected, escaping or not would change which of these two rows comes back.
+ final String punctuatedTitle = "ABC (XETRA: DB) / sqlpath" + System.nanoTime();
+ final Contentlet withPunctuation = new ContentletDataGen(
+ TestDataUtils.getWikiLikeContentType().id())
+ .setProperty("title", punctuatedTitle)
+ .folder(folder)
+ .host(site)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+
+ final BrowserQuery query = BrowserQuery.builder()
+ .withUser(APILocator.systemUser())
+ .withHostOrFolderId(folder.getIdentifier())
+ .showContent(true)
+ .showFolders(false)
+ .showWorking(true)
+ .withFilter(punctuatedTitle)
+ // Deliberately NOT calling useElasticsearchFiltering(true) or searchScope(...): this
+ // is the exact shape WebAssetHelper, BrowserAjax and DotCMSMacroWebAPI build today.
+ .build();
+
+ assertFalse("A BrowserQuery built the way the other callers build it must not opt into ES "
+ + "filtering on its own — that is what keeps them off the path this feature "
+ + "changes",
+ query.useElasticsearchFiltering);
+
+ final List results = browserAPI.getFolderContentList(query);
+ final Set identifiers =
+ results.stream().map(Treeable::getIdentifier).collect(Collectors.toSet());
+
+ // The SQL ILIKE path (BrowserAPIImpl#appendFilterQuery) matches substrings of the whole
+ // serialized contentlet case-insensitively, so a title match here is expected — the point
+ // is that it neither throws nor silently drops the row, which is what a leak from the ES
+ // fix into this path would look like.
+ assertTrue("A caller that never opts into ES filtering must still find a reserved-character "
+ + "title via the ordinary SQL path, unaffected by the Title-scope or "
+ + "literal-text changes",
+ identifiers.contains(withPunctuation.getIdentifier()));
+ }
+
// --- Issue #37186 (User Story 1): warm-up eliminates the concurrent thundering herd ------
//
// Freshly-created users are guaranteed cache-misses on their first resolution, so no manual
diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveFieldFilterTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveFieldFilterTest.java
index 5472d6cb4628..3edd00f6724e 100644
--- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveFieldFilterTest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveFieldFilterTest.java
@@ -818,4 +818,43 @@ public void testMultipleContentTypesReturns400() {
fail("Expected a BadRequestException, got: " + e.getClass().getName());
}
}
+
+ /**
+ * FR-030 / SC-012: field filters must match every character of the Lucene reserved set
+ * literally, exactly as SC-010 requires for the search box.
+ *
+ *
Convergence finding (2026-09-15): {@link #testMalformedDateBoundIsSafe} and the reserved
+ * character coverage seeded in {@code ContentDriveLiteralTextSearchTest} each check one fixed
+ * string containing a handful of reserved characters — not the exhaustive, one-title-per-
+ * character sweep {@code ContentDriveLiteralTextSearchTest#everyReservedCharacterInATitle_}
+ * {@code isFoundBySearchingItVerbatim} performs for the search box. This is that sweep, on a
+ * Text field via {@code userSearchable} instead of on the title via {@code filters.text}.
+ */
+ @Test
+ public void textFieldFilter_matchesEveryReservedCharacterLiterally()
+ throws DotDataException, DotSecurityException {
+ final char[] reserved = {
+ '\\', '+', '-', '!', '(', ')', ':', '^', '[', ']', '"', '{', '}', '~', '*', '?', '|',
+ '&', '/'
+ };
+ final StringBuilder failures = new StringBuilder();
+ for (final char c : reserved) {
+ final String value = "reservedfieldprobe" + System.nanoTime() + c + "marker";
+ final Contentlet probe = new ContentletDataGen(typeWithFields.id())
+ .setProperty("title", "Reserved field probe " + c)
+ .setProperty(TEXT_VAR, value)
+ .folder(testFolder)
+ .nextPersisted();
+
+ final Set inodes = driveInodes(baseRequest()
+ .userSearchable(Map.of(TEXT_VAR, value))
+ .build());
+
+ if (!inodes.contains(probe.getInode())) {
+ failures.append("\n - not found: field value containing '").append(c).append('\'');
+ }
+ }
+ assertTrue("Field-filter values containing reserved characters were not findable by their "
+ + "own text:" + failures, failures.length() == 0);
+ }
}
diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveLiteralTextSearchTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveLiteralTextSearchTest.java
new file mode 100644
index 000000000000..67958288bff2
--- /dev/null
+++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveLiteralTextSearchTest.java
@@ -0,0 +1,258 @@
+package com.dotcms.rest.api.v1.drive;
+
+import com.dotcms.DataProviderWeldRunner;
+import com.dotcms.IntegrationTestBase;
+import com.dotcms.browser.BrowserAPIImpl.PaginatedContents;
+import com.dotcms.contenttype.model.type.BaseContentType;
+import com.dotcms.contenttype.model.field.TextField;
+import com.dotcms.datagen.ContentTypeDataGen;
+import com.dotcms.datagen.FieldDataGen;
+import com.dotcms.datagen.ContentletDataGen;
+import com.dotcms.datagen.FolderDataGen;
+import com.dotcms.datagen.SiteDataGen;
+import com.dotcms.contenttype.model.type.ContentType;
+import com.dotcms.util.IntegrationTestInitService;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.portlets.contentlet.model.IndexPolicy;
+import com.dotmarketing.portlets.folders.model.Folder;
+import com.dotmarketing.util.Logger;
+import com.liferay.portal.model.User;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import javax.enterprise.context.ApplicationScoped;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Regression test for issue #37532 — a Content Drive search term must be matched as literal
+ * text, never as Lucene {@code query_string} syntax.
+ *
+ *
Reported through customer ticket 39185: filtering on a value containing {@code :}, {@code (}
+ * or {@code /} returns "No results found" for content the user is looking at. The term is read as
+ * query syntax, the query fails to parse, the failure is logged and discarded, and the caller
+ * receives an empty result set indistinguishable from a genuine miss.
+ *
+ *
The issue was raised against the Content Search portlet, but the same defect reaches Content
+ * Drive: {@code GlobalSearchAttributeStrategy} escapes only its final clause, leaving the
+ * mandatory gate — the clause that decides whether a document matches at all — built from
+ * raw user input.
+ *
+ *
These tests must FAIL before the fix. A passing run against unmodified code would mean
+ * the defect does not reach Content Drive, and the premise of the spec would need revisiting.
+ *
+ * @see #37532
+ */
+@ApplicationScoped
+@RunWith(DataProviderWeldRunner.class)
+public class ContentDriveLiteralTextSearchTest extends IntegrationTestBase {
+
+ private static final ContentDriveHelper contentDriveHelper = new ContentDriveHelper();
+
+ /** The exact headline reported on customer ticket 39185. */
+ private static final String TICKET_39185_HEADLINE =
+ "ABC Bank (XETRA: DBKGn.DB / NYSE: DB) and PSL Launch independent European CLO "
+ + "Total Return Indices";
+
+ /**
+ * The Lucene {@code query_string} reserved set. Each character gets its own seeded title so a
+ * failure names exactly which one is still being read as syntax (SC-010).
+ */
+ private static final char[] RESERVED = {
+ '\\', '+', '-', '!', '(', ')', ':', '^', '[', ']', '"', '{', '}', '~', '*', '?', '|',
+ '&', '/'
+ };
+
+ /** A searchable text field, so the same reserved set can be exercised through field filters. */
+ private static final String TOPIC_VAR = "topic";
+
+ private static User systemUser;
+ private static String assetPath;
+ private static String fieldFilterInode;
+ private static Host testSite;
+ private static ContentType testType;
+
+ /** Seeded title → inode, so an assertion can prove the right row came back. */
+ private static final Map seeded = new LinkedHashMap<>();
+
+ @BeforeClass
+ public static void prepare() throws Exception {
+ IntegrationTestInitService.getInstance().init();
+ systemUser = APILocator.getUserAPI().getSystemUser();
+ final long languageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
+
+ final String uniqueId = System.currentTimeMillis() + "";
+ testSite = new SiteDataGen().name("literal-text-" + uniqueId + ".local").nextPersisted();
+ final Folder folder =
+ new FolderDataGen().name("literalFolder_" + uniqueId).site(testSite).nextPersisted();
+ assetPath = "//" + testSite.getHostname() + folder.getPath();
+
+ testType = new ContentTypeDataGen()
+ .name("LiteralTextType_" + uniqueId)
+ .velocityVarName("literalTextType_" + uniqueId)
+ .baseContentType(BaseContentType.CONTENT)
+ .host(testSite)
+ .nextPersisted();
+
+ new FieldDataGen().type(TextField.class).name(TOPIC_VAR).velocityVarName(TOPIC_VAR)
+ .contentTypeId(testType.id()).searchable(true).indexed(true).nextPersisted();
+
+ seed(TICKET_39185_HEADLINE, folder, languageId);
+
+ // For the field-filter half of #37532: a searchable field value carrying reserved
+ // characters, filtered through userSearchable rather than the search box.
+ fieldFilterInode = new ContentletDataGen(testType.id())
+ .setProperty("title", "fieldfilter" + uniqueId)
+ .setProperty(TOPIC_VAR, "ABC (XETRA: DB) / topic" + uniqueId)
+ .folder(folder)
+ .languageId(languageId)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted()
+ .getInode();
+ for (final char c : RESERVED) {
+ // A distinct, searchable title per reserved character. The marker keeps the titles
+ // unique to this run so a stray match from elsewhere cannot make the test pass.
+ seed("reserved" + uniqueId + c + "marker", folder, languageId);
+ }
+
+ Logger.info(ContentDriveLiteralTextSearchTest.class,
+ String.format("Seeded %d titles under %s", seeded.size(), assetPath));
+ }
+
+ private static void seed(final String title, final Folder folder, final long languageId) {
+ final Contentlet item = new ContentletDataGen(testType.id())
+ .setProperty("title", title)
+ .folder(folder)
+ .languageId(languageId)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ seeded.put(title, item.getInode());
+ }
+
+ @AfterClass
+ public static void cleanup() {
+ try {
+ if (null != testType) {
+ APILocator.getContentTypeAPI(systemUser).delete(testType);
+ }
+ } catch (final Exception e) {
+ Logger.warn(ContentDriveLiteralTextSearchTest.class,
+ "Could not delete test content type: " + e.getMessage());
+ }
+ try {
+ if (null != testSite) {
+ APILocator.getHostAPI().archive(testSite, systemUser, false);
+ APILocator.getHostAPI().delete(testSite, systemUser, false);
+ }
+ } catch (final Exception e) {
+ Logger.warn(ContentDriveLiteralTextSearchTest.class,
+ "Could not delete test site: " + e.getMessage());
+ }
+ }
+
+ private PaginatedContents search(final String term)
+ throws DotDataException, DotSecurityException {
+ return contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath)
+ .showFolders(false)
+ .live(false)
+ .archived(false)
+ .offset(0)
+ .maxResults(100)
+ .filters(QueryFilters.builder().text(term).build())
+ .build(), systemUser);
+ }
+
+ private static boolean contains(final PaginatedContents results, final String inode) {
+ return results.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .anyMatch(inode::equals);
+ }
+
+ /**
+ * The other half of #37532: a term that once produced a query Elasticsearch could not parse
+ * must now simply run. This is the case that used to fail, get logged, and reach the user as
+ * "No results found".
+ *
+ *
Note what this test does not claim. An earlier attempt made the browsing service
+ * raise query failures instead of swallowing them; it was reverted. Once the term is escaped,
+ * no user input can break the query, so what remained was infrastructure failure — and raising
+ * it broke the guarantee that a Lucene-injection attempt is escaped, matches nothing, and does
+ * not produce a 500 ({@code ContentDriveFieldFilterTest#testMalformedDateBoundIsSafe}).
+ * Failures the front end can observe still surface there as an error banner rather than an
+ * empty grid.
+ */
+ @Test
+ public void injectionShapedTerm_runsSafely_andMatchesNothing() throws Exception {
+ final PaginatedContents results = search("not-a-title\"] OR title:*");
+
+ assertTrue("An injection-shaped term must be escaped and simply match nothing, without "
+ + "breaking the query or leaking other content",
+ results.list.stream()
+ .noneMatch(item -> seeded.containsValue((String) item.get("inode"))));
+ }
+
+ /**
+ * #37532 also asks for equivalent behavior in Content Drive's field filters. Those route
+ * through {@code TextFieldStrategy}, which already escapes via {@code LuceneQueryUtils} and
+ * already drops empty tokens — so this test is expected to pass before the search-box fix
+ * as well as after. It exists so a regression there would be caught, and so the issue's
+ * corresponding criterion is met by evidence rather than by inspection.
+ */
+ @Test
+ public void fieldFilterValue_withReservedCharacters_matchesLiterally() throws Exception {
+ final String uniquePart = assetPath.substring(assetPath.indexOf("literalFolder_") + 14)
+ .replace("/", "");
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath)
+ .contentTypes(java.util.List.of(testType.variable()))
+ .showFolders(false).live(false).archived(false).offset(0).maxResults(100)
+ .userSearchable(java.util.Map.of(TOPIC_VAR, "ABC (XETRA: DB) / topic" + uniquePart))
+ .build(), systemUser);
+
+ assertTrue("A field-filter value containing reserved characters must match literally",
+ contains(results, fieldFilterInode));
+ }
+
+ /**
+ * The customer's case, end to end. Searching for the exact headline of a piece of content must
+ * return that content.
+ */
+ @Test
+ public void ticket39185Headline_isFoundBySearchingItVerbatim() throws Exception {
+ final PaginatedContents results = search(TICKET_39185_HEADLINE);
+ assertTrue(
+ "The ticket 39185 headline was not returned when searched verbatim. This is the "
+ + "customer-reported defect: the colons, parentheses and slash are being "
+ + "read as query syntax instead of as part of the name.",
+ contains(results, seeded.get(TICKET_39185_HEADLINE)));
+ }
+
+ /**
+ * Every character of the reserved set, one seeded title each, so a failure names the offending
+ * character rather than reporting a generic miss (SC-010).
+ */
+ @Test
+ public void everyReservedCharacterInATitle_isFoundBySearchingItVerbatim() throws Exception {
+ final StringBuilder failures = new StringBuilder();
+ for (final Map.Entry entry : seeded.entrySet()) {
+ if (entry.getKey().equals(TICKET_39185_HEADLINE)) {
+ continue;
+ }
+ final PaginatedContents results = search(entry.getKey());
+ if (!contains(results, entry.getValue())) {
+ failures.append("\n - not found: '").append(entry.getKey()).append('\'');
+ }
+ }
+ assertTrue("Titles containing reserved characters were not findable by their own text:"
+ + failures, failures.length() == 0);
+ }
+}
diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveSearchScopeTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveSearchScopeTest.java
new file mode 100644
index 000000000000..93b473a1589c
--- /dev/null
+++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveSearchScopeTest.java
@@ -0,0 +1,530 @@
+package com.dotcms.rest.api.v1.drive;
+
+import com.dotcms.DataProviderWeldRunner;
+import com.dotcms.IntegrationTestBase;
+import com.dotcms.browser.BrowserAPIImpl.PaginatedContents;
+import com.dotcms.contenttype.model.field.TextField;
+import com.dotcms.contenttype.model.type.BaseContentType;
+import com.dotcms.contenttype.model.type.ContentType;
+import com.dotcms.datagen.ContentTypeDataGen;
+import com.dotcms.datagen.ContentletDataGen;
+import com.dotcms.datagen.FieldDataGen;
+import com.dotcms.datagen.FolderDataGen;
+import com.dotcms.datagen.SiteDataGen;
+import com.dotcms.util.IntegrationTestInitService;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.portlets.contentlet.model.IndexPolicy;
+import com.dotmarketing.portlets.folders.model.Folder;
+import com.dotmarketing.util.Logger;
+import com.liferay.portal.model.User;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import javax.enterprise.context.ApplicationScoped;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Integration tests for the Content Drive search scope (issue #37479): the two-option control that
+ * says which fields a search term is matched against.
+ *
+ *
These live here rather than in a unit test on purpose. {@code buildBaseESQuery} is
+ * package-private, but asserting on it needs a {@code BrowserAPIImpl} instance and a
+ * {@code BrowserQuery}, and constructing either initialises the database layer — the plan's original
+ * claim that this was unit-testable did not survive contact with the code.
+ *
+ *
The seeded fixture is the shape the user story describes: one document whose title
+ * carries the term, and one whose title does not but whose body does.
+ *
+ * @see #37479
+ */
+@ApplicationScoped
+@RunWith(DataProviderWeldRunner.class)
+public class ContentDriveSearchScopeTest extends IntegrationTestBase {
+
+ private static final ContentDriveHelper contentDriveHelper = new ContentDriveHelper();
+ private static final String BODY_VAR = "body";
+
+ private static User systemUser;
+ private static String assetPath;
+ private static Host testSite;
+ private static ContentType testType;
+
+ /** The search term. Unique per run so nothing else in the index can satisfy an assertion. */
+ private static String term;
+ /** Title contains the term. Must be returned in BOTH scopes. */
+ private static String titleMatchInode;
+ /** Title does NOT contain the term; the body does. Returned in All Fields only. */
+ private static String bodyOnlyMatchInode;
+ /** Title has neither probe word; the body has both. The multi-word leak detector. */
+ private static String bodyHasBothWordsInode;
+ /** A folder whose NAME contains the term — scope-independent, must appear in both. */
+ private static String folderName;
+
+ @BeforeClass
+ public static void prepare() throws Exception {
+ IntegrationTestInitService.getInstance().init();
+ systemUser = APILocator.getUserAPI().getSystemUser();
+ final long languageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
+
+ final String uniqueId = System.currentTimeMillis() + "";
+ term = "scopeterm" + uniqueId;
+
+ testSite = new SiteDataGen().name("scope-" + uniqueId + ".local").nextPersisted();
+ final Folder root =
+ new FolderDataGen().name("scopeRoot_" + uniqueId).site(testSite).nextPersisted();
+ assetPath = "//" + testSite.getHostname() + root.getPath();
+
+ // A folder whose own name carries the term. Folders never reach the index — they are
+ // narrowed in Java on their name — so this must behave identically in both scopes.
+ folderName = term + "folder";
+ // A child folder takes its site from its parent; passing .site() as well would create it
+ // at the site root instead, where this search would never see it.
+ new FolderDataGen().name(folderName).parent(root).nextPersisted();
+
+ testType = new ContentTypeDataGen()
+ .name("ScopeType_" + uniqueId).velocityVarName("scopeType_" + uniqueId)
+ .baseContentType(BaseContentType.CONTENT).host(testSite).nextPersisted();
+ new FieldDataGen().type(TextField.class).name(BODY_VAR).velocityVarName(BODY_VAR)
+ .contentTypeId(testType.id()).searchable(true).indexed(true).nextPersisted();
+
+ titleMatchInode = seed(term + " in the title", "unrelated body copy", root, languageId);
+ // Carries BOTH words of the multi-word probe in its BODY and neither in its title. A Title
+ // search for " stylesheet" must never return it.
+ bodyHasBothWordsInode = seed("a heading with neither word " + uniqueId,
+ term + " stylesheet appears only in this body", root, languageId);
+ bodyOnlyMatchInode = seed("a plain heading " + uniqueId, "the body mentions " + term,
+ root, languageId);
+
+ Logger.info(ContentDriveSearchScopeTest.class, String.format(
+ "Seeded term=%s titleMatch=%s bodyOnly=%s under %s",
+ term, titleMatchInode, bodyOnlyMatchInode, assetPath));
+ }
+
+ private static String seed(final String title, final String body, final Folder folder,
+ final long languageId) {
+ final Contentlet item = new ContentletDataGen(testType.id())
+ .setProperty("title", title)
+ .setProperty(BODY_VAR, body)
+ .folder(folder)
+ .languageId(languageId)
+ .setPolicy(IndexPolicy.WAIT_FOR)
+ .nextPersisted();
+ return item.getInode();
+ }
+
+ @AfterClass
+ public static void cleanup() {
+ try {
+ if (null != testType) {
+ APILocator.getContentTypeAPI(systemUser).delete(testType);
+ }
+ } catch (final Exception e) {
+ Logger.warn(ContentDriveSearchScopeTest.class, "type cleanup: " + e.getMessage());
+ }
+ try {
+ if (null != testSite) {
+ APILocator.getHostAPI().archive(testSite, systemUser, false);
+ APILocator.getHostAPI().delete(testSite, systemUser, false);
+ }
+ } catch (final Exception e) {
+ Logger.warn(ContentDriveSearchScopeTest.class, "site cleanup: " + e.getMessage());
+ }
+ }
+
+ /** Runs a Content Drive search. A null scope means the field is omitted entirely. */
+ private PaginatedContents search(final SearchScope scope, final boolean showFolders)
+ throws DotDataException, DotSecurityException {
+ final QueryFilters.Builder filters = QueryFilters.builder().text(term);
+ if (null != scope) {
+ filters.searchScope(scope);
+ }
+ return contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath)
+ .showFolders(showFolders)
+ .live(false).archived(false).offset(0).maxResults(100)
+ .filters(filters.build())
+ .build(), systemUser);
+ }
+
+ private static boolean contains(final PaginatedContents results, final String inode) {
+ return results.list.stream()
+ .map(item -> (String) item.get("inode"))
+ .anyMatch(inode::equals);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // FR-008 / FR-009 — what each scope returns.
+ // -----------------------------------------------------------------------------------------
+
+ /** FR-008: Title scope returns a title match and excludes a body-only match. */
+ @Test
+ public void titleScope_returnsTitleMatch_andExcludesBodyOnlyMatch() throws Exception {
+ final PaginatedContents results = search(SearchScope.TITLE, false);
+
+ assertTrue("The document whose TITLE carries the term must be returned in Title scope",
+ contains(results, titleMatchInode));
+ assertFalse("A document whose term appears only in the body must NOT be returned in "
+ + "Title scope — this is the narrowing the feature exists for",
+ contains(results, bodyOnlyMatchInode));
+ }
+
+ /** FR-009: All Fields returns both, exactly as the drive does today. */
+ @Test
+ public void allFieldsScope_returnsBothTitleAndBodyMatches() throws Exception {
+ final PaginatedContents results = search(SearchScope.ALL_FIELDS, false);
+
+ assertTrue("All Fields must return the title match", contains(results, titleMatchInode));
+ assertTrue("All Fields must return the body-only match — that is today's behaviour",
+ contains(results, bodyOnlyMatchInode));
+ }
+
+ /**
+ * FR-017 / SC-005: a request that OMITS the scope must behave exactly like one that names
+ * All Fields. This is the requirement that protects the Asset Picker, which never sends it.
+ */
+ @Test
+ public void omittedScope_behavesExactlyLikeAllFields() throws Exception {
+ final PaginatedContents omitted = search(null, false);
+ final PaginatedContents explicit = search(SearchScope.ALL_FIELDS, false);
+
+ assertEquals("An omitted scope must return the same number of results as an explicit "
+ + "ALL_FIELDS request", explicit.list.size(), omitted.list.size());
+ assertTrue(contains(omitted, titleMatchInode));
+ assertTrue(contains(omitted, bodyOnlyMatchInode));
+ }
+
+ /**
+ * FR-011: folders never reach the search index — they are narrowed in Java on their own name —
+ * so a folder whose name matches must appear in BOTH scopes, identically.
+ */
+ @Test
+ public void folderNameMatching_isIdenticalInBothScopes() throws Exception {
+ // Prove the fixture before comparing scopes: an unfiltered listing must show the folder,
+ // otherwise a "both scopes agree" assertion would pass on two empty sets.
+ final PaginatedContents unfiltered = contentDriveHelper.driveSearch(
+ DriveRequestForm.builder().assetPath(assetPath).showFolders(true)
+ .live(false).archived(false).offset(0).maxResults(100).build(),
+ systemUser);
+ final java.util.List unfilteredNames = unfiltered.list.stream()
+ .map(String::valueOf).collect(java.util.stream.Collectors.toList());
+ Logger.info(ContentDriveSearchScopeTest.class, "SCOPE unfiltered listing: " + unfilteredNames);
+ assertTrue("Fixture check: the seeded folder must be visible in an unfiltered listing, "
+ + "otherwise this test proves nothing. Saw: " + unfilteredNames,
+ unfilteredNames.stream().anyMatch(row -> row.contains(folderName)));
+
+ final PaginatedContents titleScoped = search(SearchScope.TITLE, true);
+ final PaginatedContents allFields = search(SearchScope.ALL_FIELDS, true);
+
+ final java.util.List titleNames = titleScoped.list.stream()
+ .map(String::valueOf).collect(java.util.stream.Collectors.toList());
+ final java.util.List allNames = allFields.list.stream()
+ .map(String::valueOf).collect(java.util.stream.Collectors.toList());
+ Logger.info(ContentDriveSearchScopeTest.class,
+ "SCOPE folders — title=" + titleNames + " allFields=" + allNames);
+
+ final long inTitle = titleNames.stream().filter(row -> row.contains(folderName)).count();
+ final long inAllFields = allNames.stream().filter(row -> row.contains(folderName)).count();
+
+ assertEquals("Folder name matching is scope-independent and must not change",
+ inAllFields, inTitle);
+ assertTrue("The folder whose name carries the term must be listed in both scopes. "
+ + "Title scope returned: " + titleNames, inTitle > 0);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // FR-018 / FR-025 — the contract rejects nonsense instead of guessing.
+ // -----------------------------------------------------------------------------------------
+
+ /**
+ * The multi-word case, which the single-word tests cannot catch.
+ *
+ *
The first implementation interpolated the whole term into one {@code title:*}
+ * clause. For a multi-word term the {@code title:} prefix binds only to the first word, so
+ * every word after it became a bare term matched against every field — "mixed case" in
+ * Title scope returned stylesheets whose body contained "case". Found in manual testing, not by
+ * the original suite, because every assertion here used a single-word term.
+ */
+ @Test
+ public void titleScope_withMultiWordTerm_doesNotLeakIntoOtherFields() throws Exception {
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(term + " stylesheet")
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+
+ assertFalse("A multi-word Title search must not match a document that carries those words "
+ + "only in its BODY. If this fails, the second word is being matched "
+ + "against every field instead of the title.",
+ contains(results, bodyHasBothWordsInode));
+ }
+
+ /**
+ * An injection-shaped term must be matched as text, not parsed as query syntax.
+ *
+ *
Asserts the result set is empty, not merely that the seeded documents are absent.
+ * The earlier version of this check asserted only the latter, and passed while the query
+ * matched everything else in the drive — the failure mode it was written to catch.
+ *
+ *
Scoped to TITLE deliberately. The all-fields path shares
+ * {@code GlobalSearchAttributeStrategy} with the Search portlet and the Relationships dialog,
+ * where the same {@code OR} handling predates this work and is tracked separately.
+ */
+ @Test
+ public void titleScope_injectionShapedTerm_matchesNothing() throws Exception {
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text("notatitle\"] OR title:*")
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+
+ assertEquals("An injection-shaped term must match nothing at all. Returning results means "
+ + "the OR survived escaping and was parsed as an operator.",
+ 0, results.list.size());
+ }
+
+ /**
+ * FR-025: the scope qualifies the text and is meaningless without it.
+ *
+ *
Exercises {@code ContentDriveHelper}'s own check directly, with {@code text("")} — the
+ * shape that reaches it. {@code text} is itself a required attribute on the
+ * {@code @Value.Immutable} {@link QueryFilters}, so a request whose JSON body omits the
+ * {@code text} key entirely never reaches this method at all: Jackson's own deserialization
+ * rejects it first, with a different message, before {@code ContentDriveHelper} runs. Both
+ * routes end in a 400; only the wording differs. The end-to-end distinction between the two —
+ * and the exact wording each produces — is covered at the endpoint layer (Postman, cases C-5a
+ * and C-5b), which is the layer that actually receives raw JSON.
+ */
+ @Test
+ public void scopeWithoutText_isRejected() throws Exception {
+ try {
+ contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath)
+ .showFolders(false).live(false).archived(false).offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text("").searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+ org.junit.Assert.fail(
+ "A search scope with no text is a contract error and must be rejected");
+ } catch (final com.dotcms.rest.exception.BadRequestException e) {
+ // The explanation travels in the HTTP response, not in getMessage(), which is the
+ // generic status line. That the request is refused at all is what this layer asserts;
+ // the wording of the message is checked at the endpoint layer (Postman).
+ assertEquals("The refusal must be a 400, not a 500",
+ javax.ws.rs.core.Response.Status.BAD_REQUEST.getStatusCode(),
+ e.getResponse().getStatus());
+ }
+ }
+
+ /**
+ * Punctuation in Title scope: the term must not break the query, and must not drag in content
+ * that does not belong — but the punctuation itself is not matchable mid-title.
+ *
+ *
That is a consequence of FR-010, not an oversight. Title scope matches by prefix, and a
+ * prefix query is not analyzed: the indexed token for {@code (XETRA:} is {@code xetra}, with the
+ * punctuation stripped at index time, while the search term keeps it. Reaching punctuation in
+ * the middle of a title needs a leading wildcard — {@code *(XETRA:*} — which is exactly what
+ * FR-010 forbids, because it turns the prefix seek into a scan of every distinct raw title and
+ * costs the scope the only thing that makes it cheaper than All Fields.
+ *
+ *
It is the same limit already accepted for mid-token matching ({@code 1004} not finding
+ * {@code IMG_1004.jpeg}), showing another face. The content stays reachable: searching the
+ * words without the symbols finds it, which is how search normally behaves. And All Fields —
+ * the default — matches the punctuated term in full, which is the path the customer case of
+ * #37532 takes.
+ *
+ *
An earlier version of this test asserted the opposite and passed, but only because the
+ * clause it exercised was leaking into every field. Fixing that leak is what exposed the real
+ * behaviour.
+ */
+ /**
+ * The customer headline of #37532, pasted whole into Title scope.
+ *
+ *
This is the case manual testing caught after the leak was fixed. Every token is mandatory,
+ * so tokens like {@code (XETRA:} and {@code NYSE:} have to match — and an escaped token never
+ * can, because a prefix query is not analyzed while the indexed token had its punctuation
+ * stripped. One unmatchable token sank the whole search, and pasting a title into Title scope
+ * returned nothing: the same symptom the customer originally reported, reached by a different
+ * route.
+ *
+ *
Stripping the punctuation instead of escaping it aligns the term with what the analyzer
+ * stored, without a leading wildcard and without leaving the title field.
+ */
+ @Test
+ public void titleScope_matchesTheCustomerHeadlinePastedWhole() throws Exception {
+ final long languageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
+ final Folder folder = APILocator.getFolderAPI()
+ .findFolderByPath(assetPath.substring(assetPath.indexOf('/', 2)), testSite,
+ systemUser, false);
+ final String headline = "ABC Bank (XETRA: DBKGn.DB / NYSE: DB) and PSL Launch independent "
+ + "European CLO Total Return Indices " + System.nanoTime();
+ final String inode = seed(headline, "unrelated body", folder, languageId);
+
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(headline)
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+
+ assertTrue("Pasting a punctuated title into Title scope must find it. Returning nothing is "
+ + "the symptom #37532 was raised about, and a search scope must not "
+ + "reintroduce it by another route.",
+ contains(results, inode));
+ }
+
+ @Test
+ public void titleScope_punctuatedTitle_isReachableByItsWords() throws Exception {
+ final long languageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
+ final Folder folder = APILocator.getFolderAPI()
+ .findFolderByPath(assetPath.substring(assetPath.indexOf('/', 2)), testSite,
+ systemUser, false);
+ final String marker = "xetraprobe" + System.nanoTime();
+ final String punctuated = "ABC (XETRA: DB) / " + marker;
+ final String inode = seed(punctuated, "unrelated body", folder, languageId);
+
+ // The words are reachable — the analyzer stripped the punctuation on both sides.
+ final PaginatedContents byWord = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(marker)
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+ assertTrue("A title carrying punctuation must still be reachable by its words",
+ contains(byWord, inode));
+
+ // The punctuated term itself must not break the query, and must not pull in anything else.
+ final PaginatedContents byPunctuated = contentDriveHelper.driveSearch(
+ DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(punctuated)
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+ assertFalse("A punctuated term must not leak unrelated content into Title scope",
+ contains(byPunctuated, bodyOnlyMatchInode));
+ assertFalse("A punctuated term must not leak unrelated content into Title scope",
+ contains(byPunctuated, bodyHasBothWordsInode));
+ }
+
+ /**
+ * All Fields — the default — does match the punctuated term in full. This is the path the
+ * customer case of #37532 takes, and the reason the limitation above is acceptable.
+ */
+ @Test
+ public void allFieldsScope_matchesThePunctuatedTermInFull() throws Exception {
+ final long languageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
+ final Folder folder = APILocator.getFolderAPI()
+ .findFolderByPath(assetPath.substring(assetPath.indexOf('/', 2)), testSite,
+ systemUser, false);
+ final String punctuated = "ABC (XETRA: DB) / allfields" + System.nanoTime();
+ final String inode = seed(punctuated, "unrelated body", folder, languageId);
+
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(punctuated)
+ .searchScope(SearchScope.ALL_FIELDS).build())
+ .build(), systemUser);
+
+ assertTrue("All Fields must match a punctuated term in full — this is the customer path",
+ contains(results, inode));
+ }
+
+ /**
+ * A hyphenated term must stay findable in Title scope. The standard tokenizer that indexes
+ * {@code title} treats the hyphen as a word separator — "COVID-19" is stored as the tokens
+ * {@code covid} and {@code 19} — so the search term must split the same way. The previous
+ * behavior stripped the hyphen instead, fusing the words into a token no document
+ * contains ("COVID19"): a title findable in All Fields silently vanished from Title scope.
+ *
+ *
Scope narrowing must survive the fix: a document whose body carries the same
+ * hyphenated term stays an All-Fields-only result.
+ */
+ @Test
+ public void titleScope_hyphenatedTerm_isReachableByItsWords() throws Exception {
+ final long languageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
+ final Folder folder = APILocator.getFolderAPI()
+ .findFolderByPath(assetPath.substring(assetPath.indexOf('/', 2)), testSite,
+ systemUser, false);
+ final String marker = "covidprobe" + System.nanoTime();
+ final String hyphenated = marker + "-19";
+
+ final String titleMatchInode = seed(hyphenated + " vaccine guidance", "unrelated body",
+ folder, languageId);
+ final String bodyOnlyInode = seed("a plain heading " + System.nanoTime(),
+ "the body mentions " + hyphenated, folder, languageId);
+
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(hyphenated)
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+
+ assertTrue("A hyphenated term must find its hyphenated title in Title scope — the "
+ + "analyzer stored the words separately, and stripping the hyphen fused "
+ + "them into a word no document contains",
+ contains(results, titleMatchInode));
+ assertFalse("Scope narrowing still holds: a body-only hyphenated match is not a Title hit",
+ contains(results, bodyOnlyInode));
+ }
+
+ /**
+ * A term made up entirely of query-syntax characters ({@code ***}, a lone {@code /}) has no
+ * usable word in it after the separators are handled, and must match NOTHING. Returning no
+ * text clause at all would silently drop the constraint and return the whole folder — the
+ * exact "term ignored" failure the injection-shaped test guards against, reached from the
+ * opposite direction, and the opposite of All Fields, which matches nothing for the same
+ * input.
+ */
+ @Test
+ public void titleScope_termOfOnlyQuerySyntax_matchesNothing() throws Exception {
+ // Sanity: the drive is not empty — the seeded title match is findable right now. This is
+ // what makes the zero below mean "matched nothing" rather than "nothing to match".
+ final PaginatedContents sanity = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(term)
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+ assertTrue("Sanity: the folder must contain a Title-scope match for the seeded term",
+ contains(sanity, titleMatchInode));
+
+ for (final String syntaxOnly : new String[] {"***", "/"}) {
+ final PaginatedContents titleScope = contentDriveHelper.driveSearch(
+ DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text(syntaxOnly)
+ .searchScope(SearchScope.TITLE).build())
+ .build(), systemUser);
+ assertEquals("A Title-scope term of only query syntax must match nothing, not "
+ + "everything — input was: " + syntaxOnly,
+ 0, titleScope.list.size());
+ }
+ }
+
+ /** All Fields matches nothing for a syntax-only term too — the two scopes agree there. */
+ @Test
+ public void allFieldsScope_termOfOnlyQuerySyntax_matchesNothing() throws Exception {
+ final PaginatedContents results = contentDriveHelper.driveSearch(DriveRequestForm.builder()
+ .assetPath(assetPath).showFolders(false).live(false).archived(false)
+ .offset(0).maxResults(100)
+ .filters(QueryFilters.builder().text("***")
+ .searchScope(SearchScope.ALL_FIELDS).build())
+ .build(), systemUser);
+
+ assertEquals("All Fields must match nothing for a syntax-only term",
+ 0, results.list.size());
+ }
+}
diff --git a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json
index 5b18894027cb..e34d6df8fc0c 100644
--- a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json
+++ b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json
@@ -1,2379 +1,2769 @@
{
- "info": {
- "_postman_id": "7d861cdb-18a9-4c48-9df0-a7d1a99406c6",
- "name": "Content Drive",
- "description": "Comprehensive tests for Content Drive search functionality including pagination, sorting, and filtering tests.",
- "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
- "_exporter_id": "5403727"
- },
- "item": [
- {
- "name": "Test Data Setup",
- "item": [
- {
- "name": "Create Test Site",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.collectionVariables.set(\"testSiteId\", jsonData.entity.identifier);",
- "pm.collectionVariables.set(\"testSiteName\", jsonData.entity.siteName);",
- "",
- "pm.test(\"Site created successfully\", function () {",
- " pm.expect(jsonData.entity.siteName).to.eql('contentdrive.test.site');",
- " pm.expect(jsonData.entity.identifier).to.not.be.empty;",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"siteName\": \"contentdrive.test.site\"\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/site",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "site"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Test Content Type for Drive Search",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.collectionVariables.set(\"testContentTypeId\", jsonData.entity[0].id);",
- "pm.collectionVariables.set(\"testContentTypeVar\", jsonData.entity[0].variable);",
- "",
- "pm.test(\"Content type created with title field\", function () {",
- " pm.expect(jsonData.entity[0].variable).to.include('driveSearchTest');",
- " pm.expect(jsonData.entity[0].fields).to.have.length.at.least(1);",
- " // Check for title field",
- " var titleField = jsonData.entity[0].fields.find(f => f.variable === 'title');",
- " pm.expect(titleField).to.not.be.undefined;",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"clazz\": \"com.dotcms.contenttype.model.type.SimpleContentType\",\n \"description\": \"Content Type for Drive Search Tests\",\n \"defaultType\": false,\n \"system\": false,\n \"folder\": \"SYSTEM_FOLDER\",\n \"name\": \"Drive Search Test {{$randomBankAccount}}\",\n \"variable\": \"driveSearchTest{{$randomBankAccount}}\",\n \"host\": \"SYSTEM_HOST\",\n \"fixed\": false,\n \"fields\": [\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextField\",\n \"indexed\": true,\n \"dataType\": \"TEXT\",\n \"readOnly\": false,\n \"required\": true,\n \"searchable\": true,\n \"listed\": true,\n \"sortOrder\": 1,\n \"unique\": false,\n \"name\": \"Title\",\n \"variable\": \"title\",\n \"fixed\": true\n },\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextAreaField\",\n \"indexed\": true,\n \"dataType\": \"LONG_TEXT\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": true,\n \"listed\": false,\n \"sortOrder\": 2,\n \"unique\": false,\n \"name\": \"Body\",\n \"variable\": \"body\"\n }\n ],\n \"workflow\": [\"d61a59e1-a49c-46f2-a929-db2b4bfa88b2\"]\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/contenttype",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "contenttype"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Test Folders",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.collectionVariables.set(\"testFolderId\", jsonData.entity.identifier);",
- "",
- "pm.test(\"Test folder created\", function () {",
- " pm.expect(jsonData.entity[0].name).to.eql('drive-test-folder');",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "\n [\"/drive-test-folder/\"]\n",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "folder",
- "createfolders",
- "{{testSiteName}}"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Test Contentlets - Alpha Items",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.test(\"Alpha contentlets created\", function () {",
- " pm.expect(jsonData.errors.length).to.eql(0);",
- " pm.expect(jsonData.entity.results.length).to.eql(3);",
- " ",
- " // Store contentlet identifiers for cleanup",
- " jsonData.entity.results.forEach((result, index) => {",
- " var contentId = Object.keys(result)[0];",
- " pm.collectionVariables.set(`alphaContentlet${index+1}Id`, result[contentId].identifier);",
- " });",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Alpha Test Content\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"This is alpha test content for drive search\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Apple Product Review\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Detailed review of Apple products\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Analytics Dashboard Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"How to use analytics dashboard effectively\"\n }\n ]\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "workflow",
- "actions",
- "default",
- "fire",
- "PUBLISH"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Test Contentlets - Beta Items",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.test(\"Beta contentlets created\", function () {",
- " pm.expect(jsonData.errors.length).to.eql(0);",
- " pm.expect(jsonData.entity.results.length).to.eql(3);",
- " ",
- " // Store contentlet identifiers for cleanup",
- " jsonData.entity.results.forEach((result, index) => {",
- " var contentId = Object.keys(result)[0];",
- " pm.collectionVariables.set(`betaContentlet${index+1}Id`, result[contentId].identifier);",
- " });",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Beta Testing Framework\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Comprehensive beta testing framework documentation\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Business Intelligence Report\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Monthly business intelligence report\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Best Practices Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Development best practices and guidelines\"\n }\n ]\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "workflow",
- "actions",
- "default",
- "fire",
- "PUBLISH"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Additional Test Folders",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.test(\"Additional folders created\", function () {",
- " pm.expect(jsonData.entity[0].name).to.include('subfolder');",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "[\"/drive-test-folder/alpha-subfolder/\"]",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "folder",
- "createfolders",
- "{{testSiteName}}"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Beta Subfolder",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var jsonData = pm.response.json();",
- "pm.test(\"Beta subfolder created\", function () {",
- " pm.expect(jsonData.entity[0].name).to.include('beta-subfolder');",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": " [\"/drive-test-folder/beta-subfolder/\"]",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "folder",
- "createfolders",
- "{{testSiteName}}"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Create Image File",
- "request": {
- "method": "PUT",
- "header": [],
- "body": {
- "mode": "formdata",
- "formdata": [
- {
- "key": "file",
- "type": "file",
- "src": "resources/image/Landscape_2008_urban_park_and_plaza_Ankaran.jpeg"
- },
- {
- "key": "json",
- "value": "{\n \"contentlet\": {\n \"contentType\":\"FileAsset\",\n \"title\":\"Test Image\",\n \"hostFolder\":\"{{testSiteName}}\"\n }\n}",
- "type": "text"
- },
- {
- "key": "",
- "value": "",
- "type": "text",
- "disabled": true
- }
- ]
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?language=1",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "workflow",
- "actions",
- "default",
- "fire",
- "PUBLISH"
- ],
- "query": [
- {
- "key": "language",
- "value": "1"
- }
- ]
- }
- },
- "response": []
- }
- ],
- "description": "Sets up test data including sites, content types, folders, and contentlets for drive search testing."
- },
- {
- "name": "Pagination Tests",
- "item": [
- {
- "name": "Basic Pagination - First Page",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Response should have pagination structure\", function () {",
- " var jsonData = pm.response.json();",
- " pm.expect(jsonData).to.have.property('entity');",
- " pm.expect(jsonData.entity).to.have.property('list');",
- " pm.expect(jsonData.entity).to.have.property('folderCount');",
- " pm.expect(jsonData.entity).to.have.property('contentCount');",
- "});",
- "",
- "pm.test(\"First page results within limit\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " pm.expect(list.length).to.be.at.most(5); // maxResults limit",
- " pm.expect(list.length).to.be.at.least(1);",
- "});",
- "",
- "pm.test(\"Contains both folders and contentlets\", function () {",
- " var jsonData = pm.response.json();",
- " var folderCount = jsonData.entity.folderCount;",
- " var contentCount = jsonData.entity.contentCount;",
- " pm.expect(folderCount + contentCount).to.be.at.least(1);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 5\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Pagination - Second Page",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Second page contentCursor handled correctly\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " // Should have remaining results or be empty if total <= 5",
- " pm.expect(list.length).to.be.at.most(5);",
- "});",
- "",
- "pm.test(\"Pagination metadata consistent\", function () {",
- " var jsonData = pm.response.json();",
- " pm.expect(jsonData.entity.folderCount).to.be.a('number');",
- " pm.expect(jsonData.entity.contentCount).to.be.a('number');",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 5,\n \"maxResults\": 5\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Large MaxResults Test",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Large maxResults respected\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " pm.expect(list.length).to.be.at.most(50); // Should not exceed total available",
- "});",
- "",
- "pm.test(\"All items returned when maxResults is large\", function () {",
- " var jsonData = pm.response.json();",
- " var totalExpected = jsonData.entity.folderCount + jsonData.entity.contentCount;",
- " pm.expect(jsonData.entity.list.length).to.be.at.most(totalExpected);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- }
- ],
- "description": "Tests pagination functionality with different contentCursor and maxResults values."
- },
- {
- "name": "Sorting Tests",
- "item": [
- {
- "name": "Sort by ModDate (Default)",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Results are sorted by modification date\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " if (list.length > 1) {",
- " for (let i = 0; i < list.length - 1; i++) {",
- " if (list[i].modDate && list[i+1].modDate) {",
- " var date1 = new Date(list[i].modDate);",
- " var date2 = new Date(list[i+1].modDate);",
- " // date1 should be LESS THAN OR EQUAL to date2 (ascending)",
- " pm.expect(date1.getTime()).to.be.at.most(date2.getTime());",
- " }",
- " }",
- " }",
- "});",
- "",
- "pm.test(\"Mixed content types present\", function () {",
- " var jsonData = pm.response.json();",
- " var folderCount = jsonData.entity.folderCount;",
- " var contentCount = jsonData.entity.contentCount;",
- " pm.expect(folderCount + contentCount).to.be.at.least(1);",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"modDate\",\n \"showFolders\": false,\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Sort by Title Ascending",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Results sorted by title ascending\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " if (list.length > 1) {",
- " // Extract contentlets with titles for sorting verification",
- " var contentletsWithTitles = list.filter(item => item.title || item.name);",
- " ",
- " if (contentletsWithTitles.length > 1) {",
- " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {",
- " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();",
- " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();",
- " pm.expect(title1.localeCompare(title2)).to.be.at.most(0);",
- " }",
- " }",
- " }",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Sort by Title Descending",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Results sorted by title descending\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " if (list.length > 1) {",
- " // Extract contentlets with titles for sorting verification",
- " var contentletsWithTitles = list.filter(item => item.title || item.name);",
- " ",
- " if (contentletsWithTitles.length > 1) {",
- " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {",
- " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();",
- " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();",
- " pm.expect(title1.localeCompare(title2)).to.be.at.least(0);",
- " }",
- " }",
- " }",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:desc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Folder and Content Mixed Sorting",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Folders and contentlets properly mixed in sort\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " var folderCount = jsonData.entity.folderCount;",
- " var contentCount = jsonData.entity.contentCount;",
- " ",
- " pm.expect(folderCount + contentCount).to.be.at.least(1);",
- " ",
- " // Verify that both folders and content can appear in the list",
- " if (list.length > 0) {",
- " var hasFolders = list.some(item => item.type === 'folder' || item.__icon__ === 'folder');",
- " var hasContent = list.some(item => item.contentType || item.type === 'contentlet');",
- " pm.expect(hasFolders || hasContent).to.be.true;",
- " }",
- "});",
- "",
- "pm.test(\"Results include test folder and test content\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // Look for our test items",
- " var hasTestFolder = list.some(item => ",
- " (item.name && item.name.includes('drive-test-folder')) ||",
- " (item.title && item.title.includes('drive-test-folder'))",
- " );",
- " var hasTestContent = list.some(item => ",
- " (item.title && (item.title.includes('Alpha') || item.title.includes('Beta'))) ||",
- " (item.name && (item.name.includes('Alpha') || item.name.includes('Beta')))",
- " );",
- " ",
- " // At least one should be present",
- " pm.expect(hasTestFolder || hasTestContent).to.be.true;",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": true, \n \"maxResults\": 20\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- }
- ],
- "description": "Tests various sorting options and their behavior with folders and contentlets."
- },
- {
- "name": "Search and Filtering Tests",
- "item": [
- {
- "name": "Search - Unfiltered Baseline (for Alpha Filter narrowing check)",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "// Captured for Text Search - Alpha Filter's narrowing assertion, run as an",
- "// ordinary preceding request (not pm.sendRequest) so it inherits the",
- "// collection's bearer auth like every other request here, and so the",
- "// narrowing check stays a plain synchronous pm.test -- this repo's Postman",
- "// collections have no other use of the async pm.test(name, done) pattern",
- "// (found in review).",
- "pm.test(\"Capture unfiltered baseline count\", function () {",
- " var jsonData = pm.response.json();",
- " pm.collectionVariables.set('unfilteredBaselineCount', jsonData.entity.list.length);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\"\n },\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Text Search - Alpha Filter",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Search results contain Alpha items\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " pm.expect(list.length).to.be.at.least(1);",
- " ",
- " // Verify that results contain 'Alpha' in title or name",
- " var hasAlphaItems = list.some(item => {",
- " var title = item.title || item.name || '';",
- " return title.toLowerCase().includes('alpha');",
- " });",
- " ",
- " pm.expect(hasAlphaItems).to.be.true;",
- "});",
- "",
- "pm.test(\"Alpha item TextArea field is previewed under its own key, not omitted\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // The test content type declares a TextArea field named body (seeded with real",
- " // content in the setup step above), and issue #37185 previews it in place under",
- " // its own key rather than omitting it -- the endpoint own integration test",
- " // asserts exactly this. A prior version of this test claimed a body key is",
- " // never present, which this content type own field contradicts (found in",
- " // review) -- asserting on it directly here is the only end-to-end proof over",
- " // REST that a TextArea field actually gets previewed.",
- " var alphaItem = list.find(function(item) { return (item.title || '').toLowerCase().includes('alpha'); });",
- " pm.expect(alphaItem, 'an Alpha item in the results').to.not.be.undefined;",
- " pm.expect(alphaItem.body, \"the previewed body field\").to.be.a(\"string\");",
- " pm.expect(alphaItem.body.length).to.be.at.most(150);",
- " pm.expect(alphaItem.body).to.not.include('<');",
- " pm.expect(alphaItem.body).to.not.include('>');",
- "});",
- "",
- "pm.test(\"Search narrows results relative to the unfiltered baseline\", function () {",
- " // The unfiltered baseline is captured by the preceding \"Search - Unfiltered",
- " // Baseline\" request into a collection variable, rather than fetched here with",
- " // pm.sendRequest -- that call does not inherit the collection's bearer auth,",
- " // so the sibling request came back 401 and threw, and the done()-callback",
- " // pattern it required has no other user anywhere in this repo's Postman",
- " // collections and is not guaranteed to be supported by the collection",
- " // runner (found in review). A plain preceding request keeps this test",
- " // synchronous and correctly authenticated like every other request here,",
- " // while still proving filtering rather than just repeating the",
- " // contains-Alpha check above.",
- " var filteredCount = pm.response.json().entity.list.length;",
- " var unfilteredCount = parseInt(pm.collectionVariables.get('unfilteredBaselineCount'), 10);",
- " ",
- " pm.expect(unfilteredCount, 'unfiltered baseline count').to.be.a('number').and.not.NaN;",
- " pm.expect(filteredCount, 'Alpha-filtered result count')",
- " .to.be.below(unfilteredCount);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Alpha\"\n },\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Text Search - Beta Filter",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Search results contain Beta items\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " pm.expect(list.length).to.be.at.least(1);",
- " ",
- " // Verify that results contain 'Beta' in title or name",
- " var hasBetaItems = list.some(item => {",
- " var title = item.title || item.name || '';",
- " return title.toLowerCase().includes('beta');",
- " });",
- " ",
- " pm.expect(hasBetaItems).to.be.true;",
- "});",
- "",
- "pm.test(\"Search with different terms returns different results\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // Store result count for comparison in collection variable",
- " pm.collectionVariables.set('betaSearchResultCount', list.length);",
- " ",
- " pm.expect(list.length).to.be.at.most(10);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Beta\"\n },\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Text Search - Partial Match",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Partial search works (Test keyword)\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " pm.expect(list.length).to.be.at.least(1);",
- " ",
- " // Verify that results contain 'Test' in title or name",
- " var hasTestItems = list.some(item => {",
- " var title = item.title || item.name || '';",
- " return title.toLowerCase().includes('test');",
- " });",
- " ",
- " pm.expect(hasTestItems).to.be.true;",
- "});",
- "",
- "pm.test(\"Elasticsearch filtering active\", function () {",
- " var jsonData = pm.response.json();",
- " // When filtering is active, results should be focused",
- " pm.expect(jsonData.entity.list.length).to.be.at.most(15);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Search with Empty Filter",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Empty filter returns all results\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // Empty filter should return more results than specific searches",
- " pm.expect(list.length).to.be.at.least(1);",
- " ",
- " // Should include both folders and contentlets",
- " var folderCount = jsonData.entity.folderCount;",
- " var contentCount = jsonData.entity.contentCount;",
- " pm.expect(folderCount + contentCount).to.be.at.least(1);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\"\n },\n \"maxResults\": 15\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Search No Results",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Non-matching search returns minimal results\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // Should return very few or no results",
- " pm.expect(list.length).to.be.at.most(2);",
- "});",
- "",
- "pm.test(\"Response structure maintained even with no results\", function () {",
- " var jsonData = pm.response.json();",
- " pm.expect(jsonData.entity).to.have.property('list');",
- " pm.expect(jsonData.entity).to.have.property('folderCount');",
- " pm.expect(jsonData.entity).to.have.property('contentCount');",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"NonExistentSearchTermThatShouldReturnNoResults12345\"\n },\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Search - MIME Type Filter",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Verify results contain 'Test Image'\", function () {",
- " const jsonData = pm.response.json();",
- " const list = jsonData.entity.list;",
- " pm.expect(list.length).to.be.at.least(1);",
- " ",
- " // Verify that results contain 'Test Image' in its title. This file asset ",
- " // is 'Landscape_2008_urban_park_and_plaza_Ankaran.jpeg', created in the Test Data Setup",
- " const hasTestImage = list.some(item => {",
- " return item.title.toLowerCase().includes('test image');",
- " });",
- " ",
- " pm.expect(hasTestImage).to.equal(true, \"Expected test image is not being returned\");",
- "});",
- ""
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"mimeTypes\": [\n \"image\"\n ],\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- }
- ],
- "description": "Tests search and filtering functionality using the filters.text parameter."
- },
- {
- "name": "Combined Tests",
- "item": [
- {
- "name": "Search with Sorting and Pagination",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Combined search, sort and pagination works\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // Should respect maxResults",
- " pm.expect(list.length).to.be.at.most(3);",
- " ",
- " // Should contain filtered results",
- " if (list.length > 0) {",
- " var hasFilteredItems = list.some(item => {",
- " var title = item.title || item.name || '';",
- " return title.toLowerCase().includes('test') || title.toLowerCase().includes('alpha') || title.toLowerCase().includes('beta');",
- " });",
- " pm.expect(hasFilteredItems).to.be.true;",
- " }",
- "});",
- "",
- "pm.test(\"Results are sorted: Folders first (by name), then Content (by title)\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- "",
- " if (list && list.length > 1) {",
- " // 1. Extract what we need into a clean, simplified array",
- " var actualOrder = list.map(function(item) {",
- " // Determine if it is a folder based on the \"type\" property",
- " var isFolder = item.type === 'folder';",
- " ",
- " // Pick the correct string to sort by",
- " var sortString = isFolder ? item.name : item.title;",
- " ",
- " return {",
- " type: isFolder ? 'folder' : 'content',",
- " text: (sortString || '').toString().toLowerCase()",
- " };",
- " });",
- "",
- " // 2. Create a copy of the actual order to sort ourselves",
- " // We use JSON parse/stringify as a quick way to deep clone the array in Postman",
- " var expectedOrder = JSON.parse(JSON.stringify(actualOrder));",
- "",
- " // 3. Apply your custom sorting rules to our expected array",
- " expectedOrder.sort(function(a, b) {",
- " // Rule A: Folders always come before Content",
- " if (a.type === 'folder' && b.type === 'content') return -1;",
- " if (a.type === 'content' && b.type === 'folder') return 1;",
- "",
- " // Rule B: If they are the SAME type, sort alphabetically",
- " return a.text.localeCompare(b.text);",
- " });",
- "",
- " // 4. Compare what the API gave us vs what our perfect sort looks like",
- " pm.expect(actualOrder).to.eql(expectedOrder);",
- " }",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"sortBy\": \"title:asc\",\n \"contentCursor\": 0,\n \"maxResults\": 3\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Deep Folder Navigation with Search",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Deep folder search works\", function () {",
- " var jsonData = pm.response.json();",
- " var list = jsonData.entity.list;",
- " ",
- " // Should return results from within the drive-test-folder",
- " pm.expect(list.length).to.be.at.least(0); // May be empty folder",
- " ",
- " // Response structure should be maintained",
- " pm.expect(jsonData.entity).to.have.property('folderCount');",
- " pm.expect(jsonData.entity).to.have.property('contentCount');",
- "});",
- "",
- "pm.test(\"Folder-specific results\", function () {",
- " var jsonData = pm.response.json();",
- " // Should show subfolders like alpha-subfolder, beta-subfolder",
- " var folderCount = jsonData.entity.folderCount;",
- " pm.expect(folderCount).to.be.at.least(0);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/drive-test-folder/\",\n \"filters\": {\n \"text\": \"subfolder\"\n },\n \"maxResults\": 10\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- }
- ],
- "description": "Tests combining search, sorting, and pagination features together."
- },
- {
- "name": "Menu Links Tests",
- "item": [
- {
- "name": "showLinks Omitted - No Links And Zeroed Link Metadata",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "",
- "pm.test(\"Link metadata is present and zeroed when showLinks is not sent\", function () {",
- " pm.expect(entity).to.have.property(\"linkCount\", 0);",
- " pm.expect(entity).to.have.property(\"hasMoreLinks\", false);",
- " pm.expect(entity).to.have.property(\"nextLinkCursor\", 0);",
- "});",
- "",
- "pm.test(\"No menu link leaks into a request that never asked for one\", function () {",
- " var links = entity.list.filter(function (item) {",
- " return item.mimeType === \"application/dotlink\";",
- " });",
- " pm.expect(links).to.be.an(\"array\").that.is.empty;",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "showLinks True - Response Carries Link Pagination Contract",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "",
- "pm.test(\"Links are a third symmetric pagination source\", function () {",
- " pm.expect(entity).to.have.property(\"linkCount\");",
- " pm.expect(entity).to.have.property(\"hasMoreLinks\");",
- " pm.expect(entity).to.have.property(\"nextLinkCursor\");",
- " pm.expect(entity.linkCount).to.be.a(\"number\");",
- " pm.expect(entity.hasMoreLinks).to.be.a(\"boolean\");",
- " pm.expect(entity.nextLinkCursor).to.be.a(\"number\");",
- "});",
- "",
- "pm.test(\"Folder and content metadata is unaffected\", function () {",
- " pm.expect(entity).to.have.property(\"folderCount\");",
- " pm.expect(entity).to.have.property(\"contentCount\");",
- " pm.expect(entity).to.have.property(\"hasMoreFolders\");",
- " pm.expect(entity).to.have.property(\"hasMoreContent\");",
- "});",
- "",
- "// This site has no menu links, so linkCount is 0 here. Positive \"a link came back\"",
- "// coverage lives in ContentDriveLinksTest -- the REST API exposes no endpoint that",
- "// can create a menu Link, so Postman cannot build the fixture.",
- "pm.test(\"linkCount agrees with the links actually in the list\", function () {",
- " var links = entity.list.filter(function (item) {",
- " return item.mimeType === \"application/dotlink\";",
- " });",
- " pm.expect(entity.linkCount).to.eql(links.length);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "showLinks False - Behaves Like Omitting The Flag",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "",
- "pm.test(\"Explicit false excludes links\", function () {",
- " pm.expect(entity.linkCount).to.eql(0);",
- " pm.expect(entity.hasMoreLinks).to.eql(false);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": false,\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "linkCursor - Accepted Alongside The Other Cursors",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "",
- "pm.test(\"All three cursors round-trip\", function () {",
- " pm.expect(entity.nextLinkCursor).to.be.at.least(0);",
- " pm.expect(entity.nextFolderCursor).to.be.at.least(0);",
- " pm.expect(entity.nextContentCursor).to.be.at.least(0);",
- "});",
- "",
- "pm.test(\"maxResults is still respected\", function () {",
- " pm.expect(entity.list.length).to.be.at.most(5);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"linkCursor\": 0,\n \"folderCursor\": 0,\n \"contentCursor\": 0,\n \"maxResults\": 5\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "showLinks With Empty baseTypes - Links Only Request",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "",
- "// Links are not a BaseContentType, so an empty baseTypes array is the documented",
- "// way to ask for links without content.",
- "pm.test(\"Empty baseTypes disables folders and content\", function () {",
- " pm.expect(entity.folderCount).to.eql(0);",
- " pm.expect(entity.contentCount).to.eql(0);",
- "});",
- "",
- "pm.test(\"Only links may appear in a links-only request\", function () {",
- " var nonLinks = entity.list.filter(function (item) {",
- " return item.mimeType !== \"application/dotlink\";",
- " });",
- " pm.expect(nonLinks).to.be.an(\"array\").that.is.empty;",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "showLinks With mimeTypes - Links Suppressed",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "",
- "// A Link carries no file MIME type, so it could never satisfy a mimeTypes filter.",
- "pm.test(\"A mimeTypes filter drops links\", function () {",
- " pm.expect(entity.linkCount).to.eql(0);",
- " pm.expect(entity.hasMoreLinks).to.eql(false);",
- "});",
- "",
- "pm.test(\"The mimeType filter itself still works\", function () {",
- " entity.list.forEach(function (item) {",
- " pm.expect(item.mimeType).to.not.eql(\"application/dotlink\");",
- " });",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"mimeTypes\": [\n \"image/jpeg\"\n ],\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- }
- },
- "response": []
- },
- {
- "name": "showLinks With filters.text - Filter Applied To Link Titles",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "var term = \"alpha\";",
- "",
- "pm.test(\"Link pagination contract survives filters.text\", function () {",
- " pm.expect(entity).to.have.property(\"linkCount\");",
- " pm.expect(entity).to.have.property(\"hasMoreLinks\");",
- " pm.expect(entity).to.have.property(\"nextLinkCursor\");",
- " pm.expect(entity.linkCount).to.be.at.least(0);",
- "});",
- "",
- "var links = entity.list.filter(function (item) {",
- " return item.mimeType === \"application/dotlink\";",
- "});",
- "",
- "pm.test(\"linkCount agrees with the links in the page\", function () {",
- " pm.expect(entity.linkCount).to.eql(links.length);",
- "});",
- "",
- "pm.test(\"Every returned link title matches the filter term\", function () {",
- " links.forEach(function (link) {",
- " pm.expect(String(link.title).toLowerCase()).to.include(term);",
- " });",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"filters\": {\n \"text\": \"alpha\"\n },\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- },
- "description": "showLinks combined with filters.text. Link titles are narrowed in memory because links are not indexed in Elasticsearch, and this combination had no coverage at all before -- not even that it returns 200 rather than erroring. Menu links cannot be created over REST, so the substantive assertions (only the matching link comes back, case-insensitively) live in the ContentDriveLinksTest integration test. What is pinned here is that the combination is accepted, that the link pagination contract survives it, and that any link returned really does match the term."
- },
- "response": []
- },
- {
- "name": "showLinks With live true - No Duplicate Links",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "var entity = pm.response.json().entity;",
- "var links = entity.list.filter(function (item) {",
- " return item.mimeType === \"application/dotlink\";",
- "});",
- "",
- "pm.test(\"live:true must never return the same link twice\", function () {",
- " var ids = links.map(function (link) { return link.identifier; });",
- " var unique = ids.filter(function (id, index) { return ids.indexOf(id) === index; });",
- " pm.expect(unique.length, JSON.stringify(ids)).to.eql(ids.length);",
- "});",
- "",
- "pm.test(\"linkCount agrees with the links in the page\", function () {",
- " pm.expect(entity.linkCount).to.eql(links.length);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "POST",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"live\": true,\n \"archived\": false,\n \"maxResults\": 50\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/drive/search",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "drive",
- "search"
- ]
- },
- "description": "Regression guard for showLinks + live:true -- the combination the redirect custom field sends as showWorking:false. Asking FolderFactoryImpl for working=false left the version-table predicate uncorrelated, so the query degenerated into a cross product and could return the same link many times. BrowserAPIImpl.getLinks now always asks for the working links and resolves 'live' by keeping the ones that carry a published version."
- },
- "response": []
- }
- ],
- "description": "Coverage for the showLinks flag and the link pagination fields on POST /api/v1/drive/search (issue #36991)."
- },
- {
- "name": "Cleanup",
- "item": [
- {
- "name": "Delete Test Content Type",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Content type deleted\", function () {",
- " var jsonData = pm.response.json();",
- " pm.expect(jsonData.errors.length).to.eql(0);",
- "});"
- ],
- "type": "text/javascript"
- }
- }
- ],
- "request": {
- "method": "DELETE",
- "header": [],
- "url": {
- "raw": "{{serverURL}}/api/v1/contenttype/id/{{testContentTypeId}}",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "contenttype",
- "id",
- "{{testContentTypeId}}"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Archive Test Site",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Site archived successfully\", function () {",
- " var jsonData = pm.response.json();",
- " pm.expect(jsonData.errors.length).to.eql(0);",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "PUT",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"archived\": true\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}/_archive",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "site",
- "{{testSiteId}}",
- "_archive"
- ]
- }
- },
- "response": []
- },
- {
- "name": "Delete Test Site",
- "event": [
- {
- "listen": "test",
- "script": {
- "exec": [
- "pm.test(\"Status code should be 200\", function () {",
- " pm.response.to.have.status(200);",
- "});",
- "",
- "pm.test(\"Site archived successfully\", function () {",
- " var jsonData = pm.response.json();",
- " pm.expect(jsonData.errors.length).to.eql(0);",
- "});"
- ],
- "type": "text/javascript",
- "packages": {},
- "requests": {}
- }
- }
- ],
- "request": {
- "method": "DELETE",
- "header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
- ],
- "body": {
- "mode": "raw",
- "raw": "{\n \"archived\": true\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
- },
- "url": {
- "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}",
- "host": [
- "{{serverURL}}"
- ],
- "path": [
- "api",
- "v1",
- "site",
- "{{testSiteId}}"
- ]
- }
- },
- "response": []
- }
- ],
- "description": "Cleanup test data created during the test run."
- }
- ],
- "auth": {
- "type": "bearer",
- "bearer": [
- {
- "key": "token",
- "value": "{{jwt}}",
- "type": "string"
- }
- ]
- },
- "event": [
- {
- "listen": "prerequest",
- "script": {
- "type": "text/javascript",
- "packages": {},
- "requests": {},
- "exec": [
- "sleep = function(milliseconds) {",
- " const start = Date.now();",
- " while (Date.now() - start < milliseconds) {}",
- " console.log(`⏱️ Delayed ${milliseconds}ms`);",
- "};",
- "",
- "sleep(3000);",
- "",
- "if (!pm.environment.get('jwt')) {",
- " console.log(\"generating....\")",
- " const serverURL = pm.environment.get('serverURL'); // Get the server URL from the environment variable",
- " const apiUrl = `${serverURL}/api/v1/apitoken`; // Construct the full API URL",
- "",
- " if (!pm.environment.get('jwt')) {",
- " const username = pm.environment.get(\"user\");",
- " const password = pm.environment.get(\"password\");",
- " const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');",
- "",
- " const requestOptions = {",
- " url: apiUrl,",
- " method: \"POST\",",
- " header: {",
- " \"accept\": \"*/*\",",
- " \"content-type\": \"application/json\",",
- " \"Authorization\": `Basic ${basicAuth}`",
- " },",
- " body: {",
- " mode: \"raw\",",
- " raw: JSON.stringify({",
- " \"expirationSeconds\": 7200,",
- " \"userId\": \"dotcms.org.1\",",
- " \"network\": \"0.0.0.0/0\",",
- " \"claims\": {\"label\": \"postman-tests\"}",
- " })",
- " }",
- " };",
- "",
- " pm.sendRequest(requestOptions, function (err, response) {",
- " if (err) {",
- " console.log(err);",
- " } else {",
- " const jwt = response.json().entity.jwt;",
- " pm.environment.set('jwt', jwt);",
- " console.log(jwt);",
- " }",
- " });",
- " }",
- "}",
- ""
- ]
- }
- },
- {
- "listen": "test",
- "script": {
- "type": "text/javascript",
- "packages": {},
- "requests": {},
- "exec": [
- ""
- ]
- }
- }
- ]
+ "info": {
+ "_postman_id": "7d861cdb-18a9-4c48-9df0-a7d1a99406c6",
+ "name": "Content Drive",
+ "description": "Comprehensive tests for Content Drive search functionality including pagination, sorting, and filtering tests.",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
+ "_exporter_id": "5403727"
+ },
+ "item": [
+ {
+ "name": "Test Data Setup",
+ "item": [
+ {
+ "name": "Create Test Site",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.collectionVariables.set(\"testSiteId\", jsonData.entity.identifier);",
+ "pm.collectionVariables.set(\"testSiteName\", jsonData.entity.siteName);",
+ "",
+ "pm.test(\"Site created successfully\", function () {",
+ " pm.expect(jsonData.entity.siteName).to.eql('contentdrive.test.site');",
+ " pm.expect(jsonData.entity.identifier).to.not.be.empty;",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"siteName\": \"contentdrive.test.site\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/site",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "site"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Test Content Type for Drive Search",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.collectionVariables.set(\"testContentTypeId\", jsonData.entity[0].id);",
+ "pm.collectionVariables.set(\"testContentTypeVar\", jsonData.entity[0].variable);",
+ "",
+ "pm.test(\"Content type created with title field\", function () {",
+ " pm.expect(jsonData.entity[0].variable).to.include('driveSearchTest');",
+ " pm.expect(jsonData.entity[0].fields).to.have.length.at.least(1);",
+ " // Check for title field",
+ " var titleField = jsonData.entity[0].fields.find(f => f.variable === 'title');",
+ " pm.expect(titleField).to.not.be.undefined;",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"clazz\": \"com.dotcms.contenttype.model.type.SimpleContentType\",\n \"description\": \"Content Type for Drive Search Tests\",\n \"defaultType\": false,\n \"system\": false,\n \"folder\": \"SYSTEM_FOLDER\",\n \"name\": \"Drive Search Test {{$randomBankAccount}}\",\n \"variable\": \"driveSearchTest{{$randomBankAccount}}\",\n \"host\": \"SYSTEM_HOST\",\n \"fixed\": false,\n \"fields\": [\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextField\",\n \"indexed\": true,\n \"dataType\": \"TEXT\",\n \"readOnly\": false,\n \"required\": true,\n \"searchable\": true,\n \"listed\": true,\n \"sortOrder\": 1,\n \"unique\": false,\n \"name\": \"Title\",\n \"variable\": \"title\",\n \"fixed\": true\n },\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextAreaField\",\n \"indexed\": true,\n \"dataType\": \"LONG_TEXT\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": true,\n \"listed\": false,\n \"sortOrder\": 2,\n \"unique\": false,\n \"name\": \"Body\",\n \"variable\": \"body\"\n }\n ],\n \"workflow\": [\"d61a59e1-a49c-46f2-a929-db2b4bfa88b2\"]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/contenttype",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "contenttype"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Test Folders",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.collectionVariables.set(\"testFolderId\", jsonData.entity.identifier);",
+ "",
+ "pm.test(\"Test folder created\", function () {",
+ " pm.expect(jsonData.entity[0].name).to.eql('drive-test-folder');",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "\n [\"/drive-test-folder/\"]\n",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "folder",
+ "createfolders",
+ "{{testSiteName}}"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Test Contentlets - Alpha Items",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.test(\"Alpha contentlets created\", function () {",
+ " pm.expect(jsonData.errors.length).to.eql(0);",
+ " pm.expect(jsonData.entity.results.length).to.eql(3);",
+ " ",
+ " // Store contentlet identifiers for cleanup",
+ " jsonData.entity.results.forEach((result, index) => {",
+ " var contentId = Object.keys(result)[0];",
+ " pm.collectionVariables.set(`alphaContentlet${index+1}Id`, result[contentId].identifier);",
+ " });",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Alpha Test Content\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"This is alpha test content for drive search\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Apple Product Review\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Detailed review of Apple products\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Analytics Dashboard Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"How to use analytics dashboard effectively\"\n }\n ]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "workflow",
+ "actions",
+ "default",
+ "fire",
+ "PUBLISH"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Test Contentlet - Search Scope Probe",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.test(\"Search scope probe contentlet created\", function () {",
+ " pm.expect(jsonData.errors.length).to.eql(0);",
+ " pm.expect(jsonData.entity.results.length).to.eql(1);",
+ "",
+ " var result = jsonData.entity.results[0];",
+ " var contentId = Object.keys(result)[0];",
+ " pm.collectionVariables.set('searchScopeProbeId', result[contentId].identifier);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Search Scope Probe Content\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"This document mentions zzzscopemarker only in its body copy\"\n }\n ]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "workflow",
+ "actions",
+ "default",
+ "fire",
+ "PUBLISH"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Test Contentlets - Beta Items",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.test(\"Beta contentlets created\", function () {",
+ " pm.expect(jsonData.errors.length).to.eql(0);",
+ " pm.expect(jsonData.entity.results.length).to.eql(3);",
+ " ",
+ " // Store contentlet identifiers for cleanup",
+ " jsonData.entity.results.forEach((result, index) => {",
+ " var contentId = Object.keys(result)[0];",
+ " pm.collectionVariables.set(`betaContentlet${index+1}Id`, result[contentId].identifier);",
+ " });",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Beta Testing Framework\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Comprehensive beta testing framework documentation\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Business Intelligence Report\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Monthly business intelligence report\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Best Practices Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Development best practices and guidelines\"\n }\n ]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "workflow",
+ "actions",
+ "default",
+ "fire",
+ "PUBLISH"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Additional Test Folders",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.test(\"Additional folders created\", function () {",
+ " pm.expect(jsonData.entity[0].name).to.include('subfolder');",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "[\"/drive-test-folder/alpha-subfolder/\"]",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "folder",
+ "createfolders",
+ "{{testSiteName}}"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Beta Subfolder",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var jsonData = pm.response.json();",
+ "pm.test(\"Beta subfolder created\", function () {",
+ " pm.expect(jsonData.entity[0].name).to.include('beta-subfolder');",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": " [\"/drive-test-folder/beta-subfolder/\"]",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "folder",
+ "createfolders",
+ "{{testSiteName}}"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Create Image File",
+ "request": {
+ "method": "PUT",
+ "header": [],
+ "body": {
+ "mode": "formdata",
+ "formdata": [
+ {
+ "key": "file",
+ "type": "file",
+ "src": "resources/image/Landscape_2008_urban_park_and_plaza_Ankaran.jpeg"
+ },
+ {
+ "key": "json",
+ "value": "{\n \"contentlet\": {\n \"contentType\":\"FileAsset\",\n \"title\":\"Test Image\",\n \"hostFolder\":\"{{testSiteName}}\"\n }\n}",
+ "type": "text"
+ },
+ {
+ "key": "",
+ "value": "",
+ "type": "text",
+ "disabled": true
+ }
+ ]
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?language=1",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "workflow",
+ "actions",
+ "default",
+ "fire",
+ "PUBLISH"
+ ],
+ "query": [
+ {
+ "key": "language",
+ "value": "1"
+ }
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "description": "Sets up test data including sites, content types, folders, and contentlets for drive search testing."
+ },
+ {
+ "name": "Pagination Tests",
+ "item": [
+ {
+ "name": "Basic Pagination - First Page",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Response should have pagination structure\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.expect(jsonData).to.have.property('entity');",
+ " pm.expect(jsonData.entity).to.have.property('list');",
+ " pm.expect(jsonData.entity).to.have.property('folderCount');",
+ " pm.expect(jsonData.entity).to.have.property('contentCount');",
+ "});",
+ "",
+ "pm.test(\"First page results within limit\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " pm.expect(list.length).to.be.at.most(5); // maxResults limit",
+ " pm.expect(list.length).to.be.at.least(1);",
+ "});",
+ "",
+ "pm.test(\"Contains both folders and contentlets\", function () {",
+ " var jsonData = pm.response.json();",
+ " var folderCount = jsonData.entity.folderCount;",
+ " var contentCount = jsonData.entity.contentCount;",
+ " pm.expect(folderCount + contentCount).to.be.at.least(1);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 5\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Pagination - Second Page",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Second page contentCursor handled correctly\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " // Should have remaining results or be empty if total <= 5",
+ " pm.expect(list.length).to.be.at.most(5);",
+ "});",
+ "",
+ "pm.test(\"Pagination metadata consistent\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.expect(jsonData.entity.folderCount).to.be.a('number');",
+ " pm.expect(jsonData.entity.contentCount).to.be.a('number');",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 5,\n \"maxResults\": 5\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Large MaxResults Test",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Large maxResults respected\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " pm.expect(list.length).to.be.at.most(50); // Should not exceed total available",
+ "});",
+ "",
+ "pm.test(\"All items returned when maxResults is large\", function () {",
+ " var jsonData = pm.response.json();",
+ " var totalExpected = jsonData.entity.folderCount + jsonData.entity.contentCount;",
+ " pm.expect(jsonData.entity.list.length).to.be.at.most(totalExpected);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "description": "Tests pagination functionality with different contentCursor and maxResults values."
+ },
+ {
+ "name": "Sorting Tests",
+ "item": [
+ {
+ "name": "Sort by ModDate (Default)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Results are sorted by modification date\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " if (list.length > 1) {",
+ " for (let i = 0; i < list.length - 1; i++) {",
+ " if (list[i].modDate && list[i+1].modDate) {",
+ " var date1 = new Date(list[i].modDate);",
+ " var date2 = new Date(list[i+1].modDate);",
+ " // date1 should be LESS THAN OR EQUAL to date2 (ascending)",
+ " pm.expect(date1.getTime()).to.be.at.most(date2.getTime());",
+ " }",
+ " }",
+ " }",
+ "});",
+ "",
+ "pm.test(\"Mixed content types present\", function () {",
+ " var jsonData = pm.response.json();",
+ " var folderCount = jsonData.entity.folderCount;",
+ " var contentCount = jsonData.entity.contentCount;",
+ " pm.expect(folderCount + contentCount).to.be.at.least(1);",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"modDate\",\n \"showFolders\": false,\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Sort by Title Ascending",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Results sorted by title ascending\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " if (list.length > 1) {",
+ " // Extract contentlets with titles for sorting verification",
+ " var contentletsWithTitles = list.filter(item => item.title || item.name);",
+ " ",
+ " if (contentletsWithTitles.length > 1) {",
+ " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {",
+ " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();",
+ " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();",
+ " pm.expect(title1.localeCompare(title2)).to.be.at.most(0);",
+ " }",
+ " }",
+ " }",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Sort by Title Descending",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Results sorted by title descending\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " if (list.length > 1) {",
+ " // Extract contentlets with titles for sorting verification",
+ " var contentletsWithTitles = list.filter(item => item.title || item.name);",
+ " ",
+ " if (contentletsWithTitles.length > 1) {",
+ " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {",
+ " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();",
+ " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();",
+ " pm.expect(title1.localeCompare(title2)).to.be.at.least(0);",
+ " }",
+ " }",
+ " }",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:desc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Folder and Content Mixed Sorting",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Folders and contentlets properly mixed in sort\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " var folderCount = jsonData.entity.folderCount;",
+ " var contentCount = jsonData.entity.contentCount;",
+ " ",
+ " pm.expect(folderCount + contentCount).to.be.at.least(1);",
+ " ",
+ " // Verify that both folders and content can appear in the list",
+ " if (list.length > 0) {",
+ " var hasFolders = list.some(item => item.type === 'folder' || item.__icon__ === 'folder');",
+ " var hasContent = list.some(item => item.contentType || item.type === 'contentlet');",
+ " pm.expect(hasFolders || hasContent).to.be.true;",
+ " }",
+ "});",
+ "",
+ "pm.test(\"Results include test folder and test content\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // Look for our test items",
+ " var hasTestFolder = list.some(item => ",
+ " (item.name && item.name.includes('drive-test-folder')) ||",
+ " (item.title && item.title.includes('drive-test-folder'))",
+ " );",
+ " var hasTestContent = list.some(item => ",
+ " (item.title && (item.title.includes('Alpha') || item.title.includes('Beta'))) ||",
+ " (item.name && (item.name.includes('Alpha') || item.name.includes('Beta')))",
+ " );",
+ " ",
+ " // At least one should be present",
+ " pm.expect(hasTestFolder || hasTestContent).to.be.true;",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": true, \n \"maxResults\": 20\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "description": "Tests various sorting options and their behavior with folders and contentlets."
+ },
+ {
+ "name": "Search and Filtering Tests",
+ "item": [
+ {
+ "name": "Search - Unfiltered Baseline (for Alpha Filter narrowing check)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "// Captured for Text Search - Alpha Filter's narrowing assertion, run as an",
+ "// ordinary preceding request (not pm.sendRequest) so it inherits the",
+ "// collection's bearer auth like every other request here, and so the",
+ "// narrowing check stays a plain synchronous pm.test -- this repo's Postman",
+ "// collections have no other use of the async pm.test(name, done) pattern",
+ "// (found in review).",
+ "pm.test(\"Capture unfiltered baseline count\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.collectionVariables.set('unfilteredBaselineCount', jsonData.entity.list.length);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\"\n },\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Text Search - Alpha Filter",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Search results contain Alpha items\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " pm.expect(list.length).to.be.at.least(1);",
+ " ",
+ " // Verify that results contain 'Alpha' in title or name",
+ " var hasAlphaItems = list.some(item => {",
+ " var title = item.title || item.name || '';",
+ " return title.toLowerCase().includes('alpha');",
+ " });",
+ " ",
+ " pm.expect(hasAlphaItems).to.be.true;",
+ "});",
+ "",
+ "pm.test(\"Alpha item TextArea field is previewed under its own key, not omitted\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // The test content type declares a TextArea field named body (seeded with real",
+ " // content in the setup step above), and issue #37185 previews it in place under",
+ " // its own key rather than omitting it -- the endpoint own integration test",
+ " // asserts exactly this. A prior version of this test claimed a body key is",
+ " // never present, which this content type own field contradicts (found in",
+ " // review) -- asserting on it directly here is the only end-to-end proof over",
+ " // REST that a TextArea field actually gets previewed.",
+ " var alphaItem = list.find(function(item) { return (item.title || '').toLowerCase().includes('alpha'); });",
+ " pm.expect(alphaItem, 'an Alpha item in the results').to.not.be.undefined;",
+ " pm.expect(alphaItem.body, \"the previewed body field\").to.be.a(\"string\");",
+ " pm.expect(alphaItem.body.length).to.be.at.most(150);",
+ " pm.expect(alphaItem.body).to.not.include('<');",
+ " pm.expect(alphaItem.body).to.not.include('>');",
+ "});",
+ "",
+ "pm.test(\"Search narrows results relative to the unfiltered baseline\", function () {",
+ " // The unfiltered baseline is captured by the preceding \"Search - Unfiltered",
+ " // Baseline\" request into a collection variable, rather than fetched here with",
+ " // pm.sendRequest -- that call does not inherit the collection's bearer auth,",
+ " // so the sibling request came back 401 and threw, and the done()-callback",
+ " // pattern it required has no other user anywhere in this repo's Postman",
+ " // collections and is not guaranteed to be supported by the collection",
+ " // runner (found in review). A plain preceding request keeps this test",
+ " // synchronous and correctly authenticated like every other request here,",
+ " // while still proving filtering rather than just repeating the",
+ " // contains-Alpha check above.",
+ " var filteredCount = pm.response.json().entity.list.length;",
+ " var unfilteredCount = parseInt(pm.collectionVariables.get('unfilteredBaselineCount'), 10);",
+ " ",
+ " pm.expect(unfilteredCount, 'unfiltered baseline count').to.be.a('number').and.not.NaN;",
+ " pm.expect(filteredCount, 'Alpha-filtered result count')",
+ " .to.be.below(unfilteredCount);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Alpha\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Text Search - Beta Filter",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Search results contain Beta items\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " pm.expect(list.length).to.be.at.least(1);",
+ " ",
+ " // Verify that results contain 'Beta' in title or name",
+ " var hasBetaItems = list.some(item => {",
+ " var title = item.title || item.name || '';",
+ " return title.toLowerCase().includes('beta');",
+ " });",
+ " ",
+ " pm.expect(hasBetaItems).to.be.true;",
+ "});",
+ "",
+ "pm.test(\"Search with different terms returns different results\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // Store result count for comparison in collection variable",
+ " pm.collectionVariables.set('betaSearchResultCount', list.length);",
+ " ",
+ " pm.expect(list.length).to.be.at.most(10);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Beta\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Text Search - Partial Match",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Partial search works (Test keyword)\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " pm.expect(list.length).to.be.at.least(1);",
+ " ",
+ " // Verify that results contain 'Test' in title or name",
+ " var hasTestItems = list.some(item => {",
+ " var title = item.title || item.name || '';",
+ " return title.toLowerCase().includes('test');",
+ " });",
+ " ",
+ " pm.expect(hasTestItems).to.be.true;",
+ "});",
+ "",
+ "pm.test(\"Elasticsearch filtering active\", function () {",
+ " var jsonData = pm.response.json();",
+ " // When filtering is active, results should be focused",
+ " pm.expect(jsonData.entity.list.length).to.be.at.most(15);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Search with Empty Filter",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Empty filter returns all results\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // Empty filter should return more results than specific searches",
+ " pm.expect(list.length).to.be.at.least(1);",
+ " ",
+ " // Should include both folders and contentlets",
+ " var folderCount = jsonData.entity.folderCount;",
+ " var contentCount = jsonData.entity.contentCount;",
+ " pm.expect(folderCount + contentCount).to.be.at.least(1);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\"\n },\n \"maxResults\": 15\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Search No Results",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Non-matching search returns minimal results\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // Should return very few or no results",
+ " pm.expect(list.length).to.be.at.most(2);",
+ "});",
+ "",
+ "pm.test(\"Response structure maintained even with no results\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.expect(jsonData.entity).to.have.property('list');",
+ " pm.expect(jsonData.entity).to.have.property('folderCount');",
+ " pm.expect(jsonData.entity).to.have.property('contentCount');",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"NonExistentSearchTermThatShouldReturnNoResults12345\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Search - MIME Type Filter",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Verify results contain 'Test Image'\", function () {",
+ " const jsonData = pm.response.json();",
+ " const list = jsonData.entity.list;",
+ " pm.expect(list.length).to.be.at.least(1);",
+ " ",
+ " // Verify that results contain 'Test Image' in its title. This file asset ",
+ " // is 'Landscape_2008_urban_park_and_plaza_Ankaran.jpeg', created in the Test Data Setup",
+ " const hasTestImage = list.some(item => {",
+ " return item.title.toLowerCase().includes('test image');",
+ " });",
+ " ",
+ " pm.expect(hasTestImage).to.equal(true, \"Expected test image is not being returned\");",
+ "});",
+ ""
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"mimeTypes\": [\n \"image\"\n ],\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "description": "Tests search and filtering functionality using the filters.text parameter."
+ },
+ {
+ "name": "Search Scope Tests",
+ "item": [
+ {
+ "name": "C-1 Omitted Scope Behaves Like All Fields",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Omitted scope finds the body-only match (All Fields behaviour)\", function () {",
+ " var list = pm.response.json().entity.list;",
+ " var probeId = pm.collectionVariables.get('searchScopeProbeId');",
+ "",
+ " var found = list.some(item => item.identifier === probeId || item.inode === probeId);",
+ " pm.expect(found).to.be.true;",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"zzzscopemarker\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "C-2 Explicit ALL_FIELDS Matches C-1",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Explicit ALL_FIELDS finds the body-only match, same as omitted\", function () {",
+ " var list = pm.response.json().entity.list;",
+ " var probeId = pm.collectionVariables.get('searchScopeProbeId');",
+ "",
+ " var found = list.some(item => item.identifier === probeId || item.inode === probeId);",
+ " pm.expect(found).to.be.true;",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"zzzscopemarker\",\n \"searchScope\": \"ALL_FIELDS\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "C-3 TITLE Scope Excludes A Body-Only Match",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"TITLE scope does NOT find a term that only appears in the body\", function () {",
+ " var list = pm.response.json().entity.list;",
+ " var probeId = pm.collectionVariables.get('searchScopeProbeId');",
+ "",
+ " var found = list.some(item => item.identifier === probeId || item.inode === probeId);",
+ " pm.expect(found).to.be.false;",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"zzzscopemarker\",\n \"searchScope\": \"TITLE\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "C-4 Unrecognized Scope Value Is Rejected",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 400\", function () {",
+ " pm.response.to.have.status(400);",
+ "});",
+ "",
+ "pm.test(\"Error message names the offending value\", function () {",
+ " var body = pm.response.text();",
+ " pm.expect(body).to.include('BOGUS_SCOPE');",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"zzzscopemarker\",\n \"searchScope\": \"BOGUS_SCOPE\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "C-5a Scope With Empty Text Is Rejected (FR-025 message)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 400\", function () {",
+ " pm.response.to.have.status(400);",
+ "});",
+ "",
+ "pm.test(\"Error message explains the scope needs text\", function () {",
+ " var body = pm.response.text();",
+ " pm.expect(body.toLowerCase()).to.include('searchscope');",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\",\n \"searchScope\": \"TITLE\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "C-5b Scope Without Text Key At All Is Also Rejected",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 400\", function () {",
+ " pm.response.to.have.status(400);",
+ "});",
+ "",
+ "pm.test(\"Rejected \u2014 text is a required field, regardless of the reason given\", function () {",
+ " var body = pm.response.text();",
+ " // `text` is @Value.Immutable-required on QueryFilters, so an omitted key is",
+ " // caught by Jackson's own deserialization before ContentDriveHelper's",
+ " // FR-025 check ever runs. Both routes reach 400; only the message differs",
+ " // (see C-5a for the FR-025-specific wording).",
+ " pm.expect(body.toLowerCase()).to.include('text');",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"searchScope\": \"TITLE\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Combined Tests",
+ "item": [
+ {
+ "name": "Search with Sorting and Pagination",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Combined search, sort and pagination works\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // Should respect maxResults",
+ " pm.expect(list.length).to.be.at.most(3);",
+ " ",
+ " // Should contain filtered results",
+ " if (list.length > 0) {",
+ " var hasFilteredItems = list.some(item => {",
+ " var title = item.title || item.name || '';",
+ " return title.toLowerCase().includes('test') || title.toLowerCase().includes('alpha') || title.toLowerCase().includes('beta');",
+ " });",
+ " pm.expect(hasFilteredItems).to.be.true;",
+ " }",
+ "});",
+ "",
+ "pm.test(\"Results are sorted: Folders first (by name), then Content (by title)\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ "",
+ " if (list && list.length > 1) {",
+ " // 1. Extract what we need into a clean, simplified array",
+ " var actualOrder = list.map(function(item) {",
+ " // Determine if it is a folder based on the \"type\" property",
+ " var isFolder = item.type === 'folder';",
+ " ",
+ " // Pick the correct string to sort by",
+ " var sortString = isFolder ? item.name : item.title;",
+ " ",
+ " return {",
+ " type: isFolder ? 'folder' : 'content',",
+ " text: (sortString || '').toString().toLowerCase()",
+ " };",
+ " });",
+ "",
+ " // 2. Create a copy of the actual order to sort ourselves",
+ " // We use JSON parse/stringify as a quick way to deep clone the array in Postman",
+ " var expectedOrder = JSON.parse(JSON.stringify(actualOrder));",
+ "",
+ " // 3. Apply your custom sorting rules to our expected array",
+ " expectedOrder.sort(function(a, b) {",
+ " // Rule A: Folders always come before Content",
+ " if (a.type === 'folder' && b.type === 'content') return -1;",
+ " if (a.type === 'content' && b.type === 'folder') return 1;",
+ "",
+ " // Rule B: If they are the SAME type, sort alphabetically",
+ " return a.text.localeCompare(b.text);",
+ " });",
+ "",
+ " // 4. Compare what the API gave us vs what our perfect sort looks like",
+ " pm.expect(actualOrder).to.eql(expectedOrder);",
+ " }",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"sortBy\": \"title:asc\",\n \"contentCursor\": 0,\n \"maxResults\": 3\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Deep Folder Navigation with Search",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Deep folder search works\", function () {",
+ " var jsonData = pm.response.json();",
+ " var list = jsonData.entity.list;",
+ " ",
+ " // Should return results from within the drive-test-folder",
+ " pm.expect(list.length).to.be.at.least(0); // May be empty folder",
+ " ",
+ " // Response structure should be maintained",
+ " pm.expect(jsonData.entity).to.have.property('folderCount');",
+ " pm.expect(jsonData.entity).to.have.property('contentCount');",
+ "});",
+ "",
+ "pm.test(\"Folder-specific results\", function () {",
+ " var jsonData = pm.response.json();",
+ " // Should show subfolders like alpha-subfolder, beta-subfolder",
+ " var folderCount = jsonData.entity.folderCount;",
+ " pm.expect(folderCount).to.be.at.least(0);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/drive-test-folder/\",\n \"filters\": {\n \"text\": \"subfolder\"\n },\n \"maxResults\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "description": "Tests combining search, sorting, and pagination features together."
+ },
+ {
+ "name": "Menu Links Tests",
+ "item": [
+ {
+ "name": "showLinks Omitted - No Links And Zeroed Link Metadata",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "",
+ "pm.test(\"Link metadata is present and zeroed when showLinks is not sent\", function () {",
+ " pm.expect(entity).to.have.property(\"linkCount\", 0);",
+ " pm.expect(entity).to.have.property(\"hasMoreLinks\", false);",
+ " pm.expect(entity).to.have.property(\"nextLinkCursor\", 0);",
+ "});",
+ "",
+ "pm.test(\"No menu link leaks into a request that never asked for one\", function () {",
+ " var links = entity.list.filter(function (item) {",
+ " return item.mimeType === \"application/dotlink\";",
+ " });",
+ " pm.expect(links).to.be.an(\"array\").that.is.empty;",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "showLinks True - Response Carries Link Pagination Contract",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "",
+ "pm.test(\"Links are a third symmetric pagination source\", function () {",
+ " pm.expect(entity).to.have.property(\"linkCount\");",
+ " pm.expect(entity).to.have.property(\"hasMoreLinks\");",
+ " pm.expect(entity).to.have.property(\"nextLinkCursor\");",
+ " pm.expect(entity.linkCount).to.be.a(\"number\");",
+ " pm.expect(entity.hasMoreLinks).to.be.a(\"boolean\");",
+ " pm.expect(entity.nextLinkCursor).to.be.a(\"number\");",
+ "});",
+ "",
+ "pm.test(\"Folder and content metadata is unaffected\", function () {",
+ " pm.expect(entity).to.have.property(\"folderCount\");",
+ " pm.expect(entity).to.have.property(\"contentCount\");",
+ " pm.expect(entity).to.have.property(\"hasMoreFolders\");",
+ " pm.expect(entity).to.have.property(\"hasMoreContent\");",
+ "});",
+ "",
+ "// This site has no menu links, so linkCount is 0 here. Positive \"a link came back\"",
+ "// coverage lives in ContentDriveLinksTest -- the REST API exposes no endpoint that",
+ "// can create a menu Link, so Postman cannot build the fixture.",
+ "pm.test(\"linkCount agrees with the links actually in the list\", function () {",
+ " var links = entity.list.filter(function (item) {",
+ " return item.mimeType === \"application/dotlink\";",
+ " });",
+ " pm.expect(entity.linkCount).to.eql(links.length);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "showLinks False - Behaves Like Omitting The Flag",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "",
+ "pm.test(\"Explicit false excludes links\", function () {",
+ " pm.expect(entity.linkCount).to.eql(0);",
+ " pm.expect(entity.hasMoreLinks).to.eql(false);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": false,\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "linkCursor - Accepted Alongside The Other Cursors",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "",
+ "pm.test(\"All three cursors round-trip\", function () {",
+ " pm.expect(entity.nextLinkCursor).to.be.at.least(0);",
+ " pm.expect(entity.nextFolderCursor).to.be.at.least(0);",
+ " pm.expect(entity.nextContentCursor).to.be.at.least(0);",
+ "});",
+ "",
+ "pm.test(\"maxResults is still respected\", function () {",
+ " pm.expect(entity.list.length).to.be.at.most(5);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"linkCursor\": 0,\n \"folderCursor\": 0,\n \"contentCursor\": 0,\n \"maxResults\": 5\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "showLinks With Empty baseTypes - Links Only Request",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "",
+ "// Links are not a BaseContentType, so an empty baseTypes array is the documented",
+ "// way to ask for links without content.",
+ "pm.test(\"Empty baseTypes disables folders and content\", function () {",
+ " pm.expect(entity.folderCount).to.eql(0);",
+ " pm.expect(entity.contentCount).to.eql(0);",
+ "});",
+ "",
+ "pm.test(\"Only links may appear in a links-only request\", function () {",
+ " var nonLinks = entity.list.filter(function (item) {",
+ " return item.mimeType !== \"application/dotlink\";",
+ " });",
+ " pm.expect(nonLinks).to.be.an(\"array\").that.is.empty;",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "showLinks With mimeTypes - Links Suppressed",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "",
+ "// A Link carries no file MIME type, so it could never satisfy a mimeTypes filter.",
+ "pm.test(\"A mimeTypes filter drops links\", function () {",
+ " pm.expect(entity.linkCount).to.eql(0);",
+ " pm.expect(entity.hasMoreLinks).to.eql(false);",
+ "});",
+ "",
+ "pm.test(\"The mimeType filter itself still works\", function () {",
+ " entity.list.forEach(function (item) {",
+ " pm.expect(item.mimeType).to.not.eql(\"application/dotlink\");",
+ " });",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"mimeTypes\": [\n \"image/jpeg\"\n ],\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "showLinks With filters.text - Filter Applied To Link Titles",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "var term = \"alpha\";",
+ "",
+ "pm.test(\"Link pagination contract survives filters.text\", function () {",
+ " pm.expect(entity).to.have.property(\"linkCount\");",
+ " pm.expect(entity).to.have.property(\"hasMoreLinks\");",
+ " pm.expect(entity).to.have.property(\"nextLinkCursor\");",
+ " pm.expect(entity.linkCount).to.be.at.least(0);",
+ "});",
+ "",
+ "var links = entity.list.filter(function (item) {",
+ " return item.mimeType === \"application/dotlink\";",
+ "});",
+ "",
+ "pm.test(\"linkCount agrees with the links in the page\", function () {",
+ " pm.expect(entity.linkCount).to.eql(links.length);",
+ "});",
+ "",
+ "pm.test(\"Every returned link title matches the filter term\", function () {",
+ " links.forEach(function (link) {",
+ " pm.expect(String(link.title).toLowerCase()).to.include(term);",
+ " });",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"filters\": {\n \"text\": \"alpha\"\n },\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ },
+ "description": "showLinks combined with filters.text. Link titles are narrowed in memory because links are not indexed in Elasticsearch, and this combination had no coverage at all before -- not even that it returns 200 rather than erroring. Menu links cannot be created over REST, so the substantive assertions (only the matching link comes back, case-insensitively) live in the ContentDriveLinksTest integration test. What is pinned here is that the combination is accepted, that the link pagination contract survives it, and that any link returned really does match the term."
+ },
+ "response": []
+ },
+ {
+ "name": "showLinks With live true - No Duplicate Links",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "var entity = pm.response.json().entity;",
+ "var links = entity.list.filter(function (item) {",
+ " return item.mimeType === \"application/dotlink\";",
+ "});",
+ "",
+ "pm.test(\"live:true must never return the same link twice\", function () {",
+ " var ids = links.map(function (link) { return link.identifier; });",
+ " var unique = ids.filter(function (id, index) { return ids.indexOf(id) === index; });",
+ " pm.expect(unique.length, JSON.stringify(ids)).to.eql(ids.length);",
+ "});",
+ "",
+ "pm.test(\"linkCount agrees with the links in the page\", function () {",
+ " pm.expect(entity.linkCount).to.eql(links.length);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"live\": true,\n \"archived\": false,\n \"maxResults\": 50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/drive/search",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "drive",
+ "search"
+ ]
+ },
+ "description": "Regression guard for showLinks + live:true -- the combination the redirect custom field sends as showWorking:false. Asking FolderFactoryImpl for working=false left the version-table predicate uncorrelated, so the query degenerated into a cross product and could return the same link many times. BrowserAPIImpl.getLinks now always asks for the working links and resolves 'live' by keeping the ones that carry a published version."
+ },
+ "response": []
+ }
+ ],
+ "description": "Coverage for the showLinks flag and the link pagination fields on POST /api/v1/drive/search (issue #36991)."
+ },
+ {
+ "name": "Cleanup",
+ "item": [
+ {
+ "name": "Delete Test Content Type",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Content type deleted\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.expect(jsonData.errors.length).to.eql(0);",
+ "});"
+ ],
+ "type": "text/javascript"
+ }
+ }
+ ],
+ "request": {
+ "method": "DELETE",
+ "header": [],
+ "url": {
+ "raw": "{{serverURL}}/api/v1/contenttype/id/{{testContentTypeId}}",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "contenttype",
+ "id",
+ "{{testContentTypeId}}"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Archive Test Site",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Site archived successfully\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.expect(jsonData.errors.length).to.eql(0);",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "PUT",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"archived\": true\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}/_archive",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "site",
+ "{{testSiteId}}",
+ "_archive"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Delete Test Site",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test(\"Status code should be 200\", function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test(\"Site archived successfully\", function () {",
+ " var jsonData = pm.response.json();",
+ " pm.expect(jsonData.errors.length).to.eql(0);",
+ "});"
+ ],
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {}
+ }
+ }
+ ],
+ "request": {
+ "method": "DELETE",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"archived\": true\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}",
+ "host": [
+ "{{serverURL}}"
+ ],
+ "path": [
+ "api",
+ "v1",
+ "site",
+ "{{testSiteId}}"
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "description": "Cleanup test data created during the test run."
+ }
+ ],
+ "auth": {
+ "type": "bearer",
+ "bearer": [
+ {
+ "key": "token",
+ "value": "{{jwt}}",
+ "type": "string"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "prerequest",
+ "script": {
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {},
+ "exec": [
+ "sleep = function(milliseconds) {",
+ " const start = Date.now();",
+ " while (Date.now() - start < milliseconds) {}",
+ " console.log(`\u23f1\ufe0f Delayed ${milliseconds}ms`);",
+ "};",
+ "",
+ "sleep(3000);",
+ "",
+ "if (!pm.environment.get('jwt')) {",
+ " console.log(\"generating....\")",
+ " const serverURL = pm.environment.get('serverURL'); // Get the server URL from the environment variable",
+ " const apiUrl = `${serverURL}/api/v1/apitoken`; // Construct the full API URL",
+ "",
+ " if (!pm.environment.get('jwt')) {",
+ " const username = pm.environment.get(\"user\");",
+ " const password = pm.environment.get(\"password\");",
+ " const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');",
+ "",
+ " const requestOptions = {",
+ " url: apiUrl,",
+ " method: \"POST\",",
+ " header: {",
+ " \"accept\": \"*/*\",",
+ " \"content-type\": \"application/json\",",
+ " \"Authorization\": `Basic ${basicAuth}`",
+ " },",
+ " body: {",
+ " mode: \"raw\",",
+ " raw: JSON.stringify({",
+ " \"expirationSeconds\": 7200,",
+ " \"userId\": \"dotcms.org.1\",",
+ " \"network\": \"0.0.0.0/0\",",
+ " \"claims\": {\"label\": \"postman-tests\"}",
+ " })",
+ " }",
+ " };",
+ "",
+ " pm.sendRequest(requestOptions, function (err, response) {",
+ " if (err) {",
+ " console.log(err);",
+ " } else {",
+ " const jwt = response.json().entity.jwt;",
+ " pm.environment.set('jwt', jwt);",
+ " console.log(jwt);",
+ " }",
+ " });",
+ " }",
+ "}",
+ ""
+ ]
+ }
+ },
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "packages": {},
+ "requests": {},
+ "exec": [
+ ""
+ ]
+ }
+ }
+ ]
}
\ No newline at end of file
diff --git a/specs/37479-content-drive-search-scope/contracts/drive-search-searchscope.md b/specs/37479-content-drive-search-scope/contracts/drive-search-searchscope.md
new file mode 100644
index 000000000000..ef0e284c5879
--- /dev/null
+++ b/specs/37479-content-drive-search-scope/contracts/drive-search-searchscope.md
@@ -0,0 +1,126 @@
+# Contract: `filters.searchScope` on `POST /api/v1/drive/search`
+
+**Feature**: `37479-content-drive-search-scope` | **Date**: 2026-09-14
+**Spec**: [spec.md](../spec.md) | **Data model**: [data-model.md](../data-model.md)
+
+Additive, optional, and backward-compatible by construction. This document is the reference the
+Postman and integration assertions are written against.
+
+> **Not in `openapi.yaml`.** `ContentDriveResource`'s `/search` method is annotated `@Hidden`
+> (`ContentDriveResource.java:83-84`) and the endpoint does not appear in the committed
+> `src/main/webapp/WEB-INF/openapi/openapi.yaml` — verified by grep. **No regeneration step applies
+> to this change.** This file is therefore the contract of record for the new field.
+
+---
+
+## The change
+
+One new optional member inside the existing `filters` object.
+
+```jsonc
+POST /api/v1/drive/search
+{
+ "assetPath": "//demo.dotcms.com/",
+ "filters": {
+ "text": "pricing",
+ "filterFolders": true,
+ "searchScope": "TITLE" // NEW — optional; omit for today's behaviour
+ },
+ "sortBy": "modDate:desc",
+ "page": 1,
+ "perPage": 40
+}
+```
+
+| Field | Type | Required | Default | Meaning |
+|---|---|---|---|---|
+| `filters.searchScope` | `string` enum | no | `"ALL_FIELDS"` | Which fields `filters.text` is matched against |
+
+**Allowed values**
+
+| Value | Behaviour |
+|---|---|
+| `"ALL_FIELDS"` | Term matched against every indexed field. Identical to today. |
+| `"TITLE"` | Term matched against the contentlet title only. |
+
+**Why inside `filters`**: `filters` already holds `text` and `filterFolders`, and `filterFolders`'
+Javadoc reads *"when text is provided"*. All three qualify the text search and mean nothing without
+it, so they travel together (spec → Review Decision 6). The **browse** scope of #37426 stays at the
+top level for the opposite reasons — it qualifies `assetPath`, and "Clear all" resets `filters`.
+
+---
+
+## Request rules
+
+| # | Rule | Response | Requirement |
+|---|---|---|---|
+| C-1 | `searchScope` omitted | Processed exactly as today | FR-017, SC-005 |
+| C-2 | `"searchScope": "ALL_FIELDS"` | Identical to C-1 — byte for byte | FR-009, SC-005 |
+| C-3 | `"searchScope": "TITLE"` | Title-only matching for contentlets | FR-008 |
+| C-4 | Unrecognized value, e.g. `"headline"` | **`400`**, message **names the offending value**; never silently defaults | FR-018 |
+| C-5 | `searchScope` present, `text` absent or blank | **`400`** — the field qualifies `text` and is meaningless alone | FR-025 |
+| C-6 | Any `text`, any scope, containing `\ + - ! ( ) : ^ [ ] " { } ~ * ? \| & /` | Matched as **literal text**; never alters query structure | FR-027 |
+| C-7 | `text` with consecutive separators, e.g. `"a b"` | No empty clause emitted; same results as the single-separator form | FR-028 |
+| C-8 | Query fails to execute | **Error response** — never `200` with an empty list presented as success | FR-029, SC-011 |
+
+> **C-4 vs. the URL.** An unrecognized value in the *browser address* resolves silently to
+> `ALL_FIELDS` (FR-015), which is not an inconsistency: a request is a contract between programs, an
+> address is a human artefact that outlives the code that wrote it. See data-model → R-2/R-4.
+
+---
+
+## Response
+
+**Unchanged.** No new field, no changed type, no changed shape. The scope alters *which rows* come
+back, never the envelope — which is why Constitution IV's `@Schema`-matches-return-type rule is not
+engaged. The only response-level change is C-8, which converts a currently-silent failure into an
+error rather than adding anything to the success path.
+
+---
+
+## Compatibility
+
+| Consumer | Sends `searchScope`? | Effect |
+|---|---|---|
+| **Content Drive** (`DotContentDriveStore` → `DotContentDriveService.search()`) | Only when the author selects `TITLE` | The feature |
+| **Asset Picker** (`with-asset-browse.feature.ts` → same service, same endpoint) | **Never** | None — C-1 guarantees today's results. This is the caller FR-017 exists to protect, named rather than covered by "other callers". |
+| Any stored/replayed request predating this change | No | None — C-1 |
+
+These are **the only two callers of this endpoint**. Three further callers reach the same underlying
+listing by other doors and never construct this request at all: `WebAssetHelper` (assets REST API),
+`BrowserAjax` (legacy admin browser), `DotCMSMacroWebAPI` (Velocity viewtool). FR-024 confines the
+change to the text-search branch precisely so the blast radius stays at two rather than six; SC-008
+measures it.
+
+---
+
+## Test matrix
+
+| Case | Layer | Asserts |
+|---|---|---|
+| C-1 omitted field | Postman | Results equal the pre-change baseline |
+| C-2 explicit default | Postman | Response equals the C-1 response |
+| C-3 Title narrows | Integration (`ContentDriveKeywordSearchTest`) | Body-only match excluded; name match kept |
+| C-4 bad value | Postman | `400` + the value appears in the message |
+| C-5 scope without text | Postman | `400` |
+| C-6 reserved set | Integration + unit | Every character in the set, both scopes (SC-010) |
+| C-6 ticket 39185 headline | Integration | Found in both scopes (SC-009) — **must fail before the change** |
+| C-7 double space | Unit (strategy) | No term-less clause in the generated query |
+| C-8 failed query | Integration + Jest | Error state, not an empty success |
+| Asset Picker untouched | Jest/Spectator | Existing specs pass **unmodified** (SC-006) |
+| Other listing callers untouched | Integration (`BrowserAPITest`) | Identical results before/after (SC-008) |
+
+## Generated-query expectations (internal, asserted by unit tests)
+
+Not part of the public contract — recorded so the Red phase has something concrete to assert
+against. Shapes follow research [R1](../research.md#r1-what-lucene-clause-implements-title-scope) and
+[R2](../research.md#r2-where-does-the-escaping-fix-go-and-does-the-plumbing-already-exist).
+
+| Scope | Mandatory gate | Forbidden in the gate |
+|---|---|---|
+| `ALL_FIELDS` | today's `catchall` + `title_dotraw` gate, **with every clause escaped** | — |
+| `TITLE` | prefix-seek clauses on the title only | `catchall` (FR-010) · any leading wildcard `*` (FR-010) |
+
+Both scopes: the term is passed through `LuceneQueryUtils.escape` **before** the system appends its
+own `*` wildcards, so the wildcards stay outside the escaped token — then through the existing
+`BrowserAPIImpl.jsonEscape` so backslashes survive into the request body (research R2).
diff --git a/specs/37479-content-drive-search-scope/data-model.md b/specs/37479-content-drive-search-scope/data-model.md
new file mode 100644
index 000000000000..b34656ce18e4
--- /dev/null
+++ b/specs/37479-content-drive-search-scope/data-model.md
@@ -0,0 +1,170 @@
+# Phase 1 Data Model: Content Drive search scope
+
+**Feature**: `37479-content-drive-search-scope` | **Date**: 2026-09-14
+**Plan**: [plan.md](./plan.md) | **Spec**: [spec.md](./spec.md)
+
+> **No persistent data model changes.** No table, column, index mapping or content-model change.
+> Every entity below is request-scoped or client-session-scoped. This is what makes the feature
+> rollback-safe by construction (plan → Legacy Impact).
+
+---
+
+## 1. `SearchScope` (new enum)
+
+Which **fields** of a document a Content Drive search term is matched against. Not to be confused
+with the **browse** scope of [#37426](https://github.com/dotCMS/core/issues/37426), which says
+*where* you are browsing (spec → Review Decision 5).
+
+| Value | Meaning | Default |
+|---|---|---|
+| `ALL_FIELDS` | The term is read against every indexed field of the document | ✅ yes |
+| `TITLE` | The term is read against the contentlet title only | no |
+
+**Rules**
+
+- **R-1** — Absent on a request ⇒ `ALL_FIELDS`, with results byte-identical to today (FR-017, SC-005).
+ Realised as Immutables `@Value.Default` so no call site does null handling.
+- **R-2** — An unrecognized value is **rejected** with a client error that names the offending value;
+ it never silently defaults (FR-018). This differs deliberately from R-4 below.
+- **R-3** — Values are the wire contract. Prose says "search scope"; the field is `searchScope`
+ (FR-023).
+- **R-4** — An unrecognized value **in the browser address** resolves to `ALL_FIELDS` with no error
+ surfaced (FR-015). A URL is not a contract and a stale link must still open.
+
+> R-2 and R-4 look contradictory and are not. A request is a contract between programs: a wrong value
+> is a bug, and failing loudly is how it gets found. An address is a human artefact that outlives the
+> code that wrote it: failing loudly there strands the user for a typo. Same value, different
+> provenance, different obligation.
+
+**Placement**: Java enum under `com.dotcms.rest.api.v1.drive` beside the other request types.
+Frontend mirror as a `const` object in `shared/constants.ts` with a derived union type
+(`TYPESCRIPT_STANDARDS.md` — `as const`, not a TS `enum`).
+
+---
+
+## 2. `QueryFilters` (existing immutable — one new member)
+
+`com.dotcms.rest.api.v1.drive.AbstractQueryFilters`, today `{ text, filterFolders }`.
+
+| Member | Type | Required | Notes |
+|---|---|---|---|
+| `text` | `String` | yes | Existing. The term the other two members qualify. |
+| `filterFolders` | `boolean` | no (default `true`) | Existing. Javadoc: *"when text is provided"*. |
+| **`searchScope`** | **`SearchScope`** | **no (default `ALL_FIELDS`)** | **New.** Which fields `text` is read against. |
+
+**Rules**
+
+- **R-5** — `searchScope` is meaningless without `text`. A request carrying a scope with no text is a
+ **contract error**, rejected in `ContentDriveHelper` beside the existing `userSearchable`
+ cross-field check (FR-025, research R4).
+- **R-6** — The member sits **inside** `filters`, not at the request's top level. All three members
+ exist to qualify the text search, so they travel together and a nonsense combination is visible
+ rather than remembered (spec → Review Decision 6).
+
+**Why not top level**: the browse scope of #37426 *does* sit at the top level, because it qualifies
+`assetPath` and because "Clear all" resets `filters` — a browse scope there could navigate you out of
+System Host. For the search scope, being cleared by "Clear all" is exactly right (FR-020). Same rule,
+opposite placement.
+
+---
+
+## 3. `BrowserQuery` (existing internal query object — one new member)
+
+`com.dotcms.browser.BrowserQuery`, the translation target of the request form.
+
+| Member | Type | Notes |
+|---|---|---|
+| `filter` | `String` | Existing. Carries `filters.text`. |
+| `useElasticsearchFiltering` | `boolean` | Existing; Content Drive forces `true` when text is present (`ContentDriveHelper:181`). |
+| **`searchScope`** | **`SearchScope`** | **New.** Defaults to `ALL_FIELDS` so the other five callers of this object are unaffected (FR-024). |
+
+**Rules**
+
+- **R-7** — Only `ContentDriveHelper` sets it. `WebAssetHelper`, `BrowserAjax`, `DotCMSMacroWebAPI`
+ and `FileAssetAPIImpl` construct `BrowserQuery` without it and therefore keep today's behaviour
+ exactly (FR-024, SC-008).
+- **R-8** — The member selects between two clause shapes in `buildBaseESQuery` and **must not** alter
+ any other part of the query: sort, paging, permissions, folder/link matching and every non-text
+ filter are untouched (FR-011, FR-012, FR-013).
+
+---
+
+## 4. Client filter state (existing — one new key)
+
+Content Drive's filter state in `dot-content-drive.store.ts`, which feeds both the URL and the filter
+chip bar.
+
+| Aspect | Behaviour |
+|---|---|
+| Key | A dedicated key — **must not be `title`**. The store already keys the *search term* as `title` (`getFilterValue('title')`), and a scope whose value is `TITLE` sitting next to a filter key named `title` is a collision waiting to happen (research R5). |
+| Written | **Only when not `ALL_FIELDS`** (FR-021). |
+| Removed | On return to `ALL_FIELDS`, and by "Clear all" (FR-020) — the key is deleted, mirroring how the search term deletes its own key when emptied (`dot-content-drive.store.ts:228-234`). |
+| Restored | From the address on load, reload and Back/Forward (FR-014). Unrecognized ⇒ `ALL_FIELDS`, silently (R-4, FR-015). |
+| Persisted | **Never.** No per-user preference; a clean entry starts at `ALL_FIELDS` (FR-016). |
+
+**Rule R-9** — the write-only-when-non-default behaviour is not cosmetic. `hasNonDefaultFilters`
+(`utils/functions.ts:334-355`) counts every key except `sharedAssets` and `languageId`, and that
+signal shows the bar's "Clear all" (`dot-filter-bar.component.html:7`). Writing the key
+unconditionally would offer "Clear all" on a completely unfiltered drive the moment someone selected
+the default (spec → Premise Correction 4).
+
+---
+
+## 5. Search outcome — failure vs. emptiness (no new type required)
+
+Not an entity so much as a **distinction that must stop being destroyed**.
+
+| State | Today | Required |
+|---|---|---|
+| Query matched nothing | empty result set | empty result set, unchanged |
+| Query failed to execute | **empty result set** (`BrowserAPIImpl:893-895` logs and returns empty) | distinguishable from the above |
+
+**Rules**
+
+- **R-10** — The browsing service must preserve the distinction; the failure stays logged (FR-029,
+ Constitution II — surfacing is added, logging is not removed).
+- **R-11** — **Content Drive alone** presents it as an error state. The other four callers keep
+ receiving today's empty result, so FR-024 holds (research R3).
+
+> How the signal is carried is an implementation choice for `/speckit-tasks`. The data-model
+> obligation is only that the two states stop being the same value. This is the piece that turns
+> ticket 39185's "No results found" into something the author can act on.
+
+---
+
+## Entity relationships
+
+```text
+POST /v1/drive/search
+└── DriveRequestForm
+ ├── filters: QueryFilters
+ │ ├── text: String ← the term
+ │ ├── filterFolders: boolean ← qualifies text
+ │ └── searchScope: SearchScope ← NEW, qualifies text (R-5, R-6)
+ └── (contentTypes, baseTypes, language, workflow, status, userSearchable, sort, paging …)
+ │
+ ▼ ContentDriveHelper — validates R-2, R-5; sets R-7
+ BrowserQuery
+ ├── filter: String
+ └── searchScope: SearchScope ← NEW, default ALL_FIELDS
+ │
+ ▼ BrowserAPIImpl.buildBaseESQuery — R-8
+ ┌───────────┴────────────┐
+ ALL_FIELDS TITLE
+ GlobalSearchAttributeStrategy sibling clause, prefix-seek only
+ (escaping fixed here — (research R1; no catchall,
+ benefits Search portlet no leading-wildcard gate)
+ + Relationships too, FR-031)
+```
+
+## Validation summary
+
+| Rule | Requirement | Enforced where | Failure mode |
+|---|---|---|---|
+| R-1 | FR-017, SC-005 | `@Value.Default` on the immutable | n/a — absence is valid |
+| R-2 | FR-018 | Request deserialization | Client error naming the value |
+| R-4 | FR-015 | Frontend URL parsing | Silent fallback to `ALL_FIELDS` |
+| R-5 | FR-025 | `ContentDriveHelper` | `BadRequestException` |
+| R-7 | FR-024, SC-008 | Construction site — other callers never set it | n/a — by omission |
+| R-9 | FR-021 | Filter facade / store | n/a — a behaviour, tested not enforced |
+| R-10, R-11 | FR-029, SC-011 | Browsing service + Content Drive layer | Error state, not empty list |
diff --git a/specs/37479-content-drive-search-scope/spec.md b/specs/37479-content-drive-search-scope/spec.md
new file mode 100644
index 000000000000..c45afe446a31
--- /dev/null
+++ b/specs/37479-content-drive-search-scope/spec.md
@@ -0,0 +1,752 @@
+# Feature Specification: Content Drive — Title / All Fields search scope, with literal-text search terms
+
+**Feature Branch**: `issue-37479-content-drive-search-scope`
+
+**Created**: 2026-09-11
+
+**Last revised**: 2026-09-15 — two requirements amended during implementation (FR-029 narrowed
+2026-09-14, FR-003 narrowed 2026-09-15 — see each requirement inline, and `tasks.md` for the full
+record). **Approved on PR #37518 at `1ce8cdd1bf` predates both amendments — re-approval is
+required before PR 2 opens.**
+
+**Status**: Draft — pending re-approval of the FR-029 and FR-003 amendments
+
+**Type**: New Feature
+
+**Resolves**: [#37479](https://github.com/dotCMS/core/issues/37479) (search scope selector) **and**
+[#37532](https://github.com/dotCMS/core/issues/37532) (Lucene query-syntax characters in search
+terms — High severity, customer ticket
+[39185](https://helpdesk.dotcms.com/a/tickets/39185)). The two were specified separately and merged
+on 2026-09-14 once #37532's defect was found to live in the very clause this feature rewrites — see
+[Why #37532 lands here](#why-37532-lands-here).
+
+**Related history**: [#36688](https://github.com/dotCMS/core/issues/36688) (replaced the broad `catchall:*kw*` wildcard with the current strategy), [#36814](https://github.com/dotCMS/core/issues/36814) (search performance at scale). Sibling in flight: [#37426](https://github.com/dotCMS/core/issues/37426) / [PR #37487](https://github.com/dotCMS/core/pull/37487) (Content Drive **browse** scopes) — the two features land fields on the same request object and are deliberately named apart; see [Review Decision 5](#review-decisions-settled-2026-09-13).
+
+**Input (#37479)**: User description: "Content Drive: add a Title / All Content scope selector to the search box. Default scope = All Content (no regression); Title mode matches strictly the contentlet title plus folder names, not fileName/metadata.name; scope persists only in the URL filters; sorting unchanged."
+
+> The issue's words are recorded verbatim above. The wide option is named **All Fields** in this spec — see [Review Decision 7](#review-decisions-settled-2026-09-13) for why "All Content" was rejected.
+
+---
+
+## Premise Corrections
+
+Six things do not survive verification against `main`. Three of them narrow the work; the fourth
+adds a rule the issue's file list has no place for; the fifth settles which query path the search
+scope actually governs; the sixth is a live customer defect sitting in the clause this feature
+rewrites.
+
+### 1. Results are **not** sorted by score when a term is present
+
+Open decision 4 asks to *"confirm that score-descending sorting holds in Title mode."* There is no
+score sorting to hold. The drive sends whatever sort the grid holds, and its default is
+modification date:
+
+- `core-web/.../dot-content-drive/portlet/src/lib/shared/constants.ts:51-54` — `DEFAULT_SORT = { field: 'modDate', order: DESC }`
+- `.../store/dot-content-drive.store.ts:157` — `sortBy: sort()?.field + ':' + sort()?.order`, unconditionally
+- `AbstractDriveRequestForm.java:81` — the server default is `SORT_BY = "modDate"` too
+
+The only trace of score sorting is a **stale comment** at `dot-content-drive.store.ts:481`
+("Since we are using scored search for the title we need to sort by score desc") sitting above code
+that does nothing of the kind. Nothing in `BrowserAPIImpl` or `ContentDriveHelper` substitutes a
+score sort when a filter is set.
+
+**Consequence for this feature**: open decision 4 is void. Sorting is out of scope entirely — both
+scopes keep sending the grid's sort, and neither introduces a scope-dependent sort rule. The stale
+comment should be corrected while the file is open (progressive enhancement), not acted upon.
+
+### 2. Folders and links are already matched on name only, in both scopes
+
+The issue frames "match against the contentlet title **(and folder/file names)** only" as new Title
+behavior. Folders and links never reach Elasticsearch at all — they are loaded from the database and
+narrowed in Java by a case-insensitive substring on their own name:
+
+- `BrowserAPIImpl.java:3026` — `folders.removeIf(f -> !f.getName().toLowerCase().contains(browserQuery.filter.toLowerCase()))`, gated on `filterFolderNames`
+- `BrowserAPIImpl.java:2908-2913` — the equivalent for links, on `Link::getTitle`, deliberately **not** gated on that flag
+
+**Consequence for this feature**: folder and link matching is scope-independent and must not change.
+The search scope governs only the Elasticsearch clause used for **contentlets**. An acceptance
+criterion that reads "Title mode returns only rows whose title or folder name matches" is already
+half-true today and stays true in All Fields mode.
+
+### 3. `buildPureESQuery` is unreachable for Content Drive under shipped configuration
+
+The issue flags the older `title:'*x*'^5 OR catchall:*x*^3 OR fileName:*x*^2` shape in
+`buildPureESQuery` (~line 610) as *"worth confirming whether the drive reaches it"*. It does not:
+`doPureESQuery` is selected only when `BROWSE_API_HEURISTIC_TYPE` is set to `PURE_ES`, and the
+shipped default is `HYBRID_SINGLE_CHUNKED_QUERY_ES` (`BrowserAPIImpl.java:701-709`), which routes to
+`buildBaseESQuery`.
+
+**Consequence for this feature**: `buildPureESQuery` is **out of scope**. Changing it would mean
+changing a code path no shipped configuration exercises, and doing so unverified is worse than
+leaving it consistent with its own heuristic.
+
+### 4. Storing the scope as a filter would offer "Clear all" on an unfiltered drive
+
+The issue's file list routes the scope through the drive's filter state — which is what carries it
+into the address — but stops there. That state also feeds the filter chip bar, and
+`hasNonDefaultFilters` (`utils/functions.ts:334-355`) treats **every** key other than `sharedAssets`
+and `languageId` as a non-default filter. That signal is exactly what shows the bar's "Clear all"
+(`dot-filter-bar.component.html:7`).
+
+So a scope written into the filters on every selection would light up "Clear all" the moment someone
+picked **All Fields** — the default — on a drive where nothing is filtered at all.
+
+**Consequence for this feature**: FR-021. The scope counts as filter state only while it differs
+from the default, mirroring how the search term already deletes its own key when it goes empty
+(`dot-content-drive.store.ts:228-234`).
+
+### 5. Content Drive's contentlet text search is always index-routed — the SQL text path is unreachable
+
+The browsing service can match a text filter two different ways. `BrowserAPIImpl` either hands the
+term to Elasticsearch or, when `useElasticsearchFiltering` is false, builds a SQL predicate that
+runs `contentlet_as_json::text ILIKE '%token%'` over the whole serialized contentlet plus the asset
+name (`BrowserAPIImpl.java:2053-2060`, `appendFilterQuery` at `:2222`). The builder's own default is
+`false` (`BrowserQuery.java:292`), so the SQL path is the fallback in general.
+
+Content Drive never takes it. `ContentDriveHelper` sets `useElasticsearchFiltering(true)`
+unconditionally whenever the request carries text (`ContentDriveHelper.java:180-184`), and
+`isUseElasticSearchForFiltering` then returns true because a text filter is present
+(`BrowserAPIImpl.java:1582-1589`).
+
+**Consequence for this feature**: the search scope has exactly one query path to govern, and
+FR-010's "no all-fields aggregate, no leading-wildcard gate" is a statement about the Elasticsearch
+clause alone. There is no second, SQL-shaped text match that a Title scope could silently fail to
+narrow. This is also what settles the ADR-0018 question — see
+[ADR-0018 alignment](#adr-0018-alignment).
+
+### 6. The search box does not treat the term as literal text today
+
+The edge case "a term is never allowed to alter the structure of the query" was written into this
+spec as a property to preserve. It is not a property the search box has. `GlobalSearchAttributeStrategy`
+escapes **only its final clause**:
+
+```java
+// :37-38 — the MANDATORY gate, built from the RAW value
+luceneQuery.append("+(catchall:").append(value).append("*^10 OR ")
+ .append(fieldName).append("_dotraw:*").append(value).append("*^2)");
+// :46-48 — escaping happens here, and applies to this clause alone
+value = value.replaceAll(SPECIAL_CHARS_TO_ESCAPE, "\\\\$1");
+luceneQuery.append("title:").append(value).append("*");
+```
+
+Three distinct defects follow, and nothing sanitizes the value upstream — `ContentDriveHelper:183`
+passes `filters().text()` straight into `withFilter`, which reaches the strategy raw:
+
+1. **The gate is built from unescaped input.** A term such as `ABC (XETRA: DB)` produces
+ `+(catchall:ABC (XETRA: DB)*^10 OR ...)`, which is not valid `query_string` syntax.
+2. **The escape set is incomplete.** `SPECIAL_CHARS_TO_ESCAPE` (`:20`) is a private regex missing
+ `/`, while `LuceneQueryUtils.LUCENE_SPECIAL_CHARS` — vendor-neutral, documented, already used by
+ `TextFieldStrategy` — covers the full reserved set including `/`.
+3. **Consecutive separators emit empty clauses.** `:40-45` splits on `[,|\s+]` with no empty-token
+ filter, so `"a b"` yields a `title:^5` clause with no term.
+
+A query that fails to parse is then **swallowed**: `BrowserAPIImpl:893-895` logs and returns an
+empty set, and the multi-query path does the same at every level. The user sees "no results" for
+content they know exists — the exact symptom of customer ticket 39185.
+
+**Consequence for this feature**: FR-027 to FR-030, and a carve-out in FR-009/SC-002, because
+fixing this **changes All Fields results** for affected terms. The parallel path is already correct
+and is the model to copy: Content Drive's *field* filters go through `TextFieldStrategy:45-46,53-54`,
+which escapes via `LuceneQueryUtils.escape` and filters empty tokens.
+
+---
+
+## Why #37532 lands here
+
+[#37532](https://github.com/dotCMS/core/issues/37532) was raised against the **Content Search
+portlet**, where `ContentletAjax.searchContentletsByUser()` builds field-filter clauses without
+escaping and swallows the resulting parse failure at `ContentletAjax.java:1058`. It is merged into
+this spec rather than kept separate for two reasons.
+
+**The issue asks for it.** It is explicitly filed as an enhancement rather than a defect fix, and
+says so in its own words: the legacy field-level query generation should be reworked rather than
+patched, and *"Content Drive is intended to replace the Content Search portlet, so the improved
+field-level search behaviour should land there."* This spec is that landing.
+
+**The defect is already here.** Premise Correction 6 shows all three failure modes — unescaped
+input, incomplete escape set, empty clauses — plus the silent swallow, living in Content Drive's
+own search box. Fixing #37479 without them would mean rewriting the exact clause that carries the
+bug and leaving the bug in it.
+
+**What this does NOT do, and it matters**: the Content Search portlet keeps its current behavior.
+A user filtering on a content type field in that portlet still gets a silent empty result set for a
+value containing `:` or `(`. Closing #37532 on this work is only honest if the team accepts that
+trade — it is the direction the issue itself sets, but the customer on ticket 39185 is using the
+portlet today, not Content Drive. If that is not acceptable, the portlet needs its own fix and
+#37532 should stay open against it. Flagged rather than assumed.
+
+Scope split, explicitly:
+
+| #37532 acceptance criterion | Where it lands |
+|---|---|
+| Values treated as literal text, not query syntax | **Here** — FR-027 (search box). Already satisfied for Content Drive field filters. |
+| Full reserved set `\ + - ! ( ) : ^ [ ] " { } ~ * ? \| & /` verified | **Here** — SC-010 |
+| Consecutive whitespace no longer emits `**` clauses | **Here** — FR-028 |
+| Failed query surfaces an error instead of a silent zero result | **Here** — FR-029 |
+| `/` added to the escape set of the global catchall path | **Here** — FR-027, by adopting `LuceneQueryUtils.escape` |
+| Regression test covering the reported headline value | **Here** — SC-009 |
+| Equivalent field-filter behaviour confirmed in Content Drive | **Here** — FR-030, as verification; the code already does it |
+| Rework of `ContentletAjax` field-filter construction | **Not here** — legacy portlet, slated for replacement |
+| Error state in the Content Search portlet's own UI | **Not here** — same reason |
+
+---
+
+## Decisions (settled 2026-09-11)
+
+The issue leaves four decisions open and marks them as needing a call before implementation. The
+issue owner settled them on 2026-09-11. **Approval of this spec (PR 1) is the record of that
+sign-off**; if any of them is reversed, the spec must be re-approved before `/speckit-plan` runs.
+
+| # | Decision | Settled as | Why |
+|---|---|---|---|
+| 1 | Default scope | **All Fields** | The no-regression choice. Every user who does nothing keeps exactly the results they get today, and the fast path is opt-in. Defaulting to Title would silently change what an existing saved workflow returns. |
+| 2 | Field coverage in Title mode | **Contentlet `title` only** — not `fileName`, not `metadata.name` | Keeps the promise the label makes, and keeps the query to a single field so the cost argument holds. File assets are still reachable by name in practice (see [Assumptions](#assumptions)). |
+| 3 | Stickiness | **URL only, per search** | The scope behaves like every other Content Drive filter: it survives reload, Back and Forward, and a shared link, and resets to the default on a clean entry. No new per-user preference storage. |
+| 4 | Sorting in Title mode | **Unchanged** | Void as asked — see [Premise Correction 1](#1-results-are-not-sorted-by-score-when-a-term-is-present). Both scopes send the grid's current sort. |
+
+## Review Decisions (settled 2026-09-13)
+
+Review round 1 on [PR #37518](https://github.com/dotCMS/core/pull/37518) raised four further calls.
+They are settled here and are part of what PR 1's approval signs off.
+
+| # | Decision | Settled as | Why |
+|---|---|---|---|
+| 5 | What the two scopes are called in prose | **"search scope"**, never bare "scope" | Content Drive is simultaneously gaining a **browse** scope (#37426) that says *where* you are browsing. Bare "scope" would name either, and both end up as fields on the same request object, so the ambiguity would outlive the specs. The sibling spec spells "browse scope" throughout for the matching half. |
+| 6 | The wire name and its home | **`filters.searchScope`**, inside the existing filters object | `AbstractQueryFilters` is `{ text, filterFolders }` today, and `filterFolders`' Javadoc says "when text is provided". Both members exist to qualify the text search, which is exactly what the search scope does — it says which fields `text` reads and means nothing without `text`. Beside `text`, they travel together and a scope with no text is visibly nonsense rather than a validation rule someone must remember. The browse scope stays top level for the opposite reasons: it qualifies `assetPath`, and "Clear all" resets `filters`, which must never be able to navigate you elsewhere. |
+| 7 | The label of the wide option | **All Fields** (values `TITLE` / `ALL_FIELDS`) | "All Content" describes a *set of content*, which is what the browse scope's **All** genuinely means. This scope does not widen which content is searched — it widens which **fields** of each document the term is read against. Naming it "All Content" would have put two different meanings of the same word on the same request. |
+| 8 | ADR-0018's `DB ∪ Index` title routing | **Out of scope, explicitly** — Title scope inherits today's index-only routing | See [ADR-0018 alignment](#adr-0018-alignment). The union is gated on title-persistence work the ADR itself defers, and no text search consults the DB title column today. Title scope neither creates nor widens that gap, and it picks the union up for free when the gated work lands. |
+
+### Merge of #37532 (settled 2026-09-14)
+
+| # | Decision | Settled as | Why |
+|---|---|---|---|
+| 9 | Whether #37532 (literal-text search terms) joins this spec | **Merged in**, for the search box only | The defect lives in the clause FR-010 rewrites, and #37532 itself directs the improved behaviour to land in Content Drive. Keeping them apart would mean rewriting the buggy clause and leaving the bug in it. See [Why #37532 lands here](#why-37532-lands-here). |
+| 10 | FR-009's "no regression" promise vs. fixing the defect | **FR-009 yields** — a carve-out, written down | Escaping the gate *changes* All Fields results for terms containing reserved characters. That is the point, but it contradicts FR-009/SC-002 as originally approved, so the exception is stated rather than smuggled in. |
+
+## ADR-0018 alignment
+
+[ADR-0018](https://github.com/dotCMS/platform-adrs/blob/main/decisions/0018-database-first-content-drive-search-with-index-deferred-text-filtering.md)
+routes the **Title** criterion to `DB ∪ Index` — the `contentlet.title` column unioned with index
+records — so that a just-saved or just-renamed item stays findable while the index catches up. This
+feature's Title scope is defined as an Elasticsearch clause and consults no DB column. That is a
+deliberate call, not an omission, for three reasons:
+
+1. **The ADR defers its own union.** It states that `contentlet.title` "is **not reliably
+ populated**", that the display title is derived and `Contentlet.title()` is `@Nullable`, and that
+ "populating `contentlet.title` reliably is a known gap to be addressed in a separate issue". The
+ ADR fixes the routing contract that will *consume* the column once it lands; it does not claim
+ the column is usable today.
+2. **No text search consults it today, in either scope.** Per
+ [Premise Correction 5](#5-content-drives-contentlet-text-search-is-always-index-routed--the-sql-text-path-is-unreachable),
+ Content Drive's contentlet text matching is index-only. The pre-existing exposure to index lag is
+ identical in All Fields scope and would be identical in Title scope. This feature does not
+ introduce it, and narrowing the clause does not deepen it — an item missing from the index is
+ missing from both scopes equally.
+3. **The union arrives for free.** When the title-persistence issue lands and the text path becomes
+ `DB-title ∪ index`, Title scope is the scope that benefits most directly, because the DB column
+ it unions in *is* the title. Nothing in this spec has to be undone for that to happen.
+
+**What this means for the plan**: the implementation must not make Title scope *harder* to union
+later — FR-026. Read-your-writes for the text path stays tracked where the ADR put it, outside this
+feature.
+
+---
+
+## Problem Statement
+
+The Content Drive search box has exactly one behavior: every term is matched against every indexed
+field of every document. An author who knows the **name** of what they are looking for has no way to
+say so. The term hits body copy, Story Block content and metadata alike, so a common word returns a
+large slice of the drive and the one row the author wanted is buried among documents that merely
+mention it.
+
+The same breadth is also the expensive part of the request. The mandatory gate of the all-fields
+query is `+(catchall:*^10 OR title_dotraw:**^2)`
+(`GlobalSearchAttributeStrategy.java:38-40`): `catchall` aggregates every field of the document, so
+a common term is cheap to look up and enormous in what it returns, and `title_dotraw:**` is a
+leading wildcard, which forces a scan over every distinct raw title rather than a prefix seek.
+
+In the drive, the matched set is not the end of the work. Matches are fed through database hydration
+and per-chunk permission filtering (`BROWSER_CONTENT_CHUNK_SIZE`, default 900) before a page can be
+returned, so a broad match multiplies database round trips and permission checks — not just index
+time. Narrowing the candidate set at the source makes everything downstream cheaper with it.
+
+So the search scope selector earns its place twice: it is the result quality authors are asking for,
+and it gives them a fast path that avoids the most expensive clause in the query.
+
+## UI Surface
+
+The ASCII diagram in the issue is the only mock — no image or design file accompanies it. What it
+establishes, and all this spec fixes, is which components are on screen:
+
+- The **search input** — the Content Drive search box as it exists today.
+- A **search scope control beside it**, offering **Title** and **All Fields**. Its label is the
+ active scope; opening it marks the active option with a check.
+- A short **explanation of what each option matches**, available from the control. The distinction
+ between "the item's name" and "anything written anywhere in the item" is not self-evident from two
+ words, and the control is new (FR-022).
+- The **placeholder** of the input, which follows the active scope.
+
+Nothing else about the control's appearance is fixed here. How the two sit together, and whether the
+explanation is a tooltip, helper text or per-option description, are implementation decisions.
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Find a known item by its name (Priority: P1)
+
+An author knows the name of the item they want — a page called "Pricing", an image called "hero" —
+and types it into the Content Drive search box. Today they get back everything whose body or blocks
+happen to contain the word. They open the search scope control next to the box, choose **Title**,
+and the list narrows to rows whose name actually matches. The placeholder changes to say *Search by
+title*, so the box states what it will do before they type again.
+
+**Why this priority**: This is the whole feature. It delivers the result quality the issue was
+raised for and the cheap query path on its own, with nothing else built. Stories 2 and 3 protect it;
+neither creates value without it.
+
+**Independent Test**: Fully testable by selecting **Title** with a term present and confirming that
+a document which contains the term only in its body or Story Block — and not in its name —
+disappears from the list, while the row whose name matches stays. Delivers the narrowing on its own.
+
+**Acceptance Scenarios**:
+
+1. **Given** the Content Drive is open with no search term, **When** the author looks at the search
+ box, **Then** a search scope control is visible next to the input, reading **All Fields**, and
+ the placeholder shows the search box's fixed default text. ~~the placeholder describes an
+ all-fields search~~ — **corrected 2026-09-15** to match FR-003 as amended: the placeholder does
+ not vary by scope.
+2. **Given** a term is present in **All Fields** scope, **When** the author opens the search scope
+ control and selects **Title**, **Then** the search re-runs immediately with the same term,
+ results are restricted to name matches, pagination returns to page 1, and the control reads
+ **Title** with a check mark beside that option.
+3. **Given** a document whose title does **not** contain the term but whose body or Story Block
+ does, **When** the search scope is **Title**, **Then** that document is absent from the results.
+4. **Given** that same document, **When** the search scope is **All Fields**, **Then** it is
+ present — the all-fields results are identical to what the drive returns today for that term.
+5. **Given** search scope **Title** and a term that matches a folder's name, **When** the search
+ runs, **Then** the folder is listed, exactly as it is in **All Fields** scope.
+6. **Given** the search scope control is open, **When** the author selects the scope that is already
+ active, **Then** nothing is re-fetched and the current page is preserved.
+7. **Given** the author has not used this control before, **When** they open it, **Then** an
+ explanation is available that distinguishes matching the item's name from matching anything
+ written anywhere in the item.
+
+---
+
+### User Story 2 - The search scope travels with the view (Priority: P2)
+
+An author narrows to **Title**, finds the row, opens it, and comes back with the browser Back
+button — the drive returns to the Title-scoped results, not to an all-fields list. They copy the
+address and send it to a colleague, who opens the same narrowed view.
+
+**Why this priority**: Without it the search scope silently resets on every reload and navigation,
+and an author who has narrowed their search loses that narrowing without being told. It is a
+correctness guarantee over Story 1 rather than a capability of its own, so it ranks below it.
+
+**Independent Test**: Fully testable by selecting **Title**, reloading the page, and confirming the
+control still reads **Title** and the results are still narrowed — then navigating away and back.
+
+**Acceptance Scenarios**:
+
+1. **Given** search scope **Title** with a term, **When** the page is reloaded, **Then** the control
+ reads **Title**, the term is preserved, and the results are the Title-scoped results.
+2. **Given** the author switched from **All Fields** to **Title**, **When** they press browser Back,
+ **Then** the view returns to the **All Fields** results for that term.
+3. **Given** a Content Drive address carrying search scope **Title**, **When** a different user
+ opens it, **Then** they see the same narrowed view, subject to their own permissions.
+4. **Given** the author enters Content Drive with no search scope in the address, **When** the drive
+ loads, **Then** the search scope is **All Fields**.
+5. **Given** an address carrying an unrecognized search scope value, **When** the drive loads,
+ **Then** the search scope falls back to **All Fields** and the drive loads normally, with no
+ error surfaced.
+6. **Given** the author clears all filters, **When** the drive reloads its results, **Then** the
+ search scope returns to **All Fields** along with the other filter defaults.
+
+---
+
+### User Story 3 - Everything that is not Content Drive is untouched (Priority: P3)
+
+A developer using the **Asset Picker**, and an integration calling the Content Drive search
+endpoint, see no change at all. The Asset Picker's search box keeps the single all-fields behavior
+it has today, with no search scope control on screen. A request that does not mention the search
+scope behaves exactly as it does now.
+
+**Why this priority**: It is a constraint on Stories 1 and 2 rather than a journey of its own, and
+it is verified by absence. It still has to be stated and tested, because the search box is shared
+and the endpoint is public.
+
+**Independent Test**: Fully testable by opening the Asset Picker and confirming no search scope
+control appears and search behaves as before, and by replaying a stored Content Drive search request
+with no `searchScope` field and comparing the results to the current ones.
+
+**Acceptance Scenarios**:
+
+1. **Given** the Asset Picker is open, **When** the author looks at its search box, **Then** no
+ search scope control is present and searching behaves exactly as it does today.
+2. **Given** a Content Drive search request that omits the search scope entirely, **When** it is
+ processed, **Then** the results are identical to today's all-fields results.
+3. **Given** a Content Drive search request that names the all-fields scope explicitly, **When** it
+ is processed, **Then** the results are identical to the request that omits it.
+4. **Given** a Content Drive search request naming a search scope value the system does not
+ recognize, **When** it is processed, **Then** it is rejected with a client error that names the
+ offending value, rather than silently widening or narrowing the results.
+5. **Given** any of the other entry points that reach the same content listing — the assets REST
+ API, the legacy admin file browser, the Velocity macro viewtool — **When** they list content
+ after this change, **Then** they return exactly what they returned before it.
+
+---
+
+### User Story 4 - A name that contains punctuation is found (Priority: P1)
+
+An author searches for `ABC Bank (XETRA: DBKGn.DB / NYSE: DB)` — the headline of a piece of content
+they are looking at in another tab. Today the drive reports "no results found". The colons,
+parentheses and slash are read as query syntax rather than as part of the name, the query fails to
+parse, the failure is logged and discarded, and the author is told their content does not exist.
+After this change the term is matched as the literal text it is, and the item is returned — in both
+search scopes.
+
+**Why this priority**: It is listed fourth but ranks P1. This is the live customer defect behind
+[#37532](https://github.com/dotCMS/core/issues/37532) (High severity, ticket 39185), and it is a
+silent wrong answer rather than a missing capability — the worst failure mode a search box has,
+because the author has no way to tell a broken query from an empty drive. Story 1 builds on the same
+clause, so the two are implemented together.
+
+**Independent Test**: Fully testable by seeding content whose title carries each character in the
+reserved set, searching for it verbatim, and confirming it is returned. Delivers the fix on its own,
+with or without the search scope control on screen.
+
+**Acceptance Scenarios**:
+
+1. **Given** content whose title is `ABC Bank (XETRA: DBKGn.DB / NYSE: DB)`, **When** the author
+ pastes that title into the search box in **All Fields** scope, **Then** the item is returned.
+2. **Given** that same content, **When** the author searches for it in **Title** scope, **Then** the
+ item is returned — the fix applies to the new clause as well as the existing one.
+3. **Given** a term containing any character of the reserved set
+ `\ + - ! ( ) : ^ [ ] " { } ~ * ? | & /`, **When** the search runs, **Then** the character is
+ matched as literal text and never alters the structure of the query.
+4. **Given** a term containing consecutive spaces or separators, **When** the search runs, **Then**
+ no empty clause is emitted and the results are the same as for the single-separator form.
+5. **Given** a search fails in a way the front end can itself observe (a network or server error
+ reaching the browser), **When** the drive renders the response, **Then** the author is shown an
+ error state, **not** an empty result list presented as a successful search. ~~Given a search
+ request that nonetheless fails to execute... the author is shown an error state~~ — **narrowed
+ 2026-09-15** to match FR-029 as amended: an internal `BrowserAPIImpl` execution failure that
+ never reaches the front end as an observable error stays logged-only, exactly as before this
+ feature; only failures the browser itself can observe are covered.
+
+---
+
+### Edge Cases
+
+- **Search scope changed with an empty term.** No search is narrowed and nothing is re-fetched
+ beyond the drive's normal unfiltered listing; the control still records the new scope so the next
+ term uses it, and the placeholder updates.
+- **Search scope changed while a search is in flight.** The later request is the one whose results
+ are shown; an earlier in-flight response never overwrites it.
+- **Term matches only folder or link names, in Title scope.** Those rows are listed — folder and
+ link matching is scope-independent ([Premise Correction 2](#2-folders-and-links-are-already-matched-on-name-only-in-both-scopes)).
+- **Multi-word term in Title scope.** The term narrows rather than widens: a row must be a name
+ match for the phrase as entered, not merely for one of its words.
+- **Term containing characters the query syntax treats specially.** Matched as literal text in both
+ scopes; a term is never allowed to alter the structure of the query. This does **not** hold today
+ ([Premise Correction 6](#6-the-search-box-does-not-treat-the-term-as-literal-text-today)) and is
+ made true by FR-027.
+- **Term that is only separators**, or that reduces to no usable token after splitting. It yields no
+ text clause at all rather than an empty one, and the drive lists as it would with no term.
+- **Search scope combined with the other Content Drive filters** — content type, language, status,
+ workflow, shared assets, per-field filters. The search scope narrows the text match only; every
+ other filter keeps applying as it does today, and combining them narrows further rather than
+ conflicting.
+- **Search scope combined with a browse scope** (#37426, in flight). The two are independent: a
+ browse scope says which slice of content is being listed, a search scope says which fields the
+ term is read against within it. Neither may change the other's answer.
+- **Search scope selected while a folder is selected in the tree.** A new search already resets the
+ folder scope to the site root; changing the search scope of an existing search behaves
+ consistently with that.
+- **Search scope set explicitly back to All Fields.** The drive returns to the state it would have
+ had if the control had never been touched: nothing recorded in the address, and no "clear all
+ filters" offered on an otherwise unfiltered drive.
+- **A file whose title was edited to something other than its file name**, searched by file name in
+ Title scope: it does not match. See [Assumptions](#assumptions).
+- **An item saved or renamed moments before the search, not yet indexed.** It is missing from the
+ results in **both** scopes, exactly as it is today — Title scope does not make this worse. See
+ [ADR-0018 alignment](#adr-0018-alignment).
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+**The control**
+
+- **FR-001**: The Content Drive search box MUST present a search scope control adjacent to the
+ search input, within the same visual container, offering exactly two options: **Title** and
+ **All Fields**.
+- **FR-002**: The control MUST display the active search scope as its label, and MUST mark the
+ active option with a check when opened.
+- **FR-003**: ~~The search input's placeholder MUST describe the active search scope, so the box
+ states what it will do before the author types.~~ **Amended 2026-09-15.** The placeholder MUST
+ NOT vary by search scope. It is always the shared search box's own default ("Search"), in both
+ Title and All Fields. Direct instruction from the issue owner during UI review — not a defect or
+ a constraint discovered while building; FR-003 as originally written was fully implementable and
+ had been implemented and tested. Consequence: User Story 1's acceptance scenario **1** (corrected
+ in place, 2026-09-15) described a scope-aware placeholder and is superseded by this wording.
+- **FR-004**: Selecting a search scope MUST re-run the current search immediately, without requiring
+ the author to retype or re-submit the term.
+- **FR-005**: Selecting a search scope MUST reset pagination to the first page.
+- **FR-006**: Re-selecting the already-active search scope MUST NOT trigger a new search.
+- **FR-007**: The search scope control MUST be reachable and operable by keyboard and MUST expose
+ its current selection to assistive technology.
+
+**Behavior**
+
+- **FR-008**: In **Title** search scope, a contentlet MUST be returned only when its title matches
+ the term. A contentlet whose term occurrence is confined to body copy, Story Block content or
+ metadata MUST NOT be returned.
+- **FR-009**: In **All Fields** search scope, results MUST be identical to what Content Drive search
+ returns today for the same term and filters — no regression of any kind. **One carve-out, and only
+ one**: terms containing Lucene reserved characters or consecutive separators, whose results change
+ by design under FR-027 and FR-028 because today's behaviour for them is a malformed query and a
+ silent empty result set. Every term outside that carve-out MUST be unchanged, and the carve-out
+ MUST NOT be used to justify any other difference.
+- **FR-010**: In **Title** search scope, the query MUST NOT use an all-fields aggregate clause, and
+ MUST NOT use a leading-wildcard term as its mandatory gate. This is the requirement that makes the
+ search scope a genuine fast path rather than a display filter.
+- **FR-011**: Folder and link name matching MUST behave identically in both search scopes.
+- **FR-012**: The active sort MUST be unaffected by the search scope; both scopes MUST apply the
+ sort the author has chosen, with the existing default.
+- **FR-013**: The search scope MUST compose with every other Content Drive filter without altering
+ their behavior.
+
+**State and contract**
+
+- **FR-014**: A non-default search scope MUST be encoded in the address alongside the other Content
+ Drive filters, and MUST be restored from it on reload and on browser Back/Forward.
+- **FR-015**: An absent or unrecognized search scope in the address MUST resolve to **All Fields**,
+ without surfacing an error.
+- **FR-016**: The search scope MUST NOT be persisted as a per-user preference; a clean entry into
+ Content Drive MUST start at **All Fields**.
+- **FR-017**: The Content Drive search request MUST carry the search scope as an optional field that
+ defaults to all-fields behavior, so a request that omits it is processed exactly as it is today.
+ **The Asset Picker is the caller this protects**: it builds its own Content Drive search request
+ (`with-asset-browse.feature.ts` → `DotContentDriveService.search()`, the same `POST /drive/search`
+ Content Drive uses) and will never name a search scope. Any future change to the default has to
+ confront the Asset Picker by name, not merely re-run Content Drive's tests.
+- **FR-018**: A request naming an unrecognized search scope value MUST be rejected with a client
+ error identifying the value, rather than silently defaulting.
+- **FR-019**: The shared search box MUST expose the search scope control as opt-in. Surfaces that do
+ not opt in — the Asset Picker today — MUST render and behave exactly as they do now.
+- **FR-020**: Clearing all filters MUST return the search scope to **All Fields**.
+- **FR-021**: The search scope MUST count as filter state only while it is not the default.
+ Selecting **All Fields** MUST leave the drive in the state it would have been in had the control
+ never been touched — in particular, it MUST NOT cause a "clear all filters" affordance to be
+ offered on a drive that is otherwise unfiltered.
+
+**Naming, blast radius and forward compatibility**
+
+- **FR-022**: The control MUST make available a short explanation of what each option matches,
+ distinguishing the item's name from anything written anywhere in the item. The two labels alone
+ MUST NOT be relied on to convey the distinction.
+- **FR-023**: The concept MUST be called **search scope** wherever it is named — in the UI copy, in
+ the address, in the request, and in the code. The request field MUST be `searchScope` and MUST sit
+ **inside the existing `filters` object**, beside `text`, whose meaning it qualifies and without
+ which it means nothing. Its values MUST be `TITLE` and `ALL_FIELDS`. Bare "scope" MUST NOT be used
+ for it, because Content Drive is concurrently gaining a **browse** scope (#37426) that is a
+ different thing in the same request.
+- **FR-024**: The change MUST stay within the text-search branch of the shared content-listing
+ service. Callers that reach that listing by another door — the assets REST API
+ (`WebAssetHelper`), the legacy admin file browser (`BrowserAjax`), the Velocity macro viewtool
+ (`DotCMSMacroWebAPI`) — MUST produce exactly the results they produce today. Widening the change
+ into shared query building would widen the blast radius from two callers to six.
+- **FR-025**: A request carrying a search scope with no text MUST be treated as the contract error
+ it is, rather than silently ignored — the field qualifies `text` and has no meaning without it.
+- **FR-026**: **Title** search scope MUST NOT foreclose ADR-0018's `DB-title ∪ index` routing. When
+ the deferred title-persistence work lands, adding the DB title column to the Title path MUST be an
+ additive change to this feature, not a rewrite of it. This feature does not implement that union
+ and does not widen the index-lag gap that exists today
+ ([ADR-0018 alignment](#adr-0018-alignment)).
+
+**Literal-text search terms (#37532)**
+
+- **FR-027**: A search term MUST be matched as literal text. **Every** clause built from the term
+ MUST escape the full Lucene `query_string` reserved set — `\ + - ! ( ) : ^ [ ] " { } ~ * ? | & /`
+ — not only the last one, and the mandatory gate in particular. The escaping MUST use the shared,
+ vendor-neutral utility rather than a strategy-private character set, so the reserved set has one
+ definition and `/` is covered. Wildcards the system adds itself MUST be applied **after** escaping
+ so they are not themselves escaped. This applies to **both** search scopes: the Title clause
+ introduced by FR-010 is subject to it exactly as the existing all-fields clause is.
+- **FR-028**: Consecutive separators in a term MUST NOT produce empty clauses. A term that reduces
+ to no usable token MUST produce no text clause at all, rather than a clause with an empty value.
+- **FR-029**: ~~A search request whose query fails to execute MUST surface an error state to the
+ author. It MUST NOT be reported as a successful search that found nothing. The failure MUST
+ remain logged, but logging MUST NOT be the only response to it.~~ **Amended 2026-09-14.** The
+ front end MUST surface, as an error state rather than an empty result, every search failure it
+ can itself observe (network and server errors reaching the browser). The **browsing service**
+ (`BrowserAPIImpl`) MUST NOT be required to distinguish "the query failed" from "nothing matched"
+ for its own internal execution failures — that distinction MUST remain logged only, exactly as
+ before this feature.
+
+ Narrowed after building the stronger version and reverting it, for two reasons: (1) FR-027's
+ escaping removes every user-reachable way to break the query, so what remained was infrastructure
+ failure only, forceable through the public API solely via a term wide enough to hit the
+ Elasticsearch boolean-clause ceiling; (2) raising it broke a security guarantee —
+ `ContentDriveFieldFilterTest#testMalformedDateBoundIsSafe` requires a Lucene-injection attempt to
+ be escaped, match nothing, and produce no 500, and surfacing query failures turned that into an
+ error response, telling an attacker their probe had landed. Consequence: #37532's UI criterion is
+ met for observable front-end failures, not for every internal query failure.
+- **FR-030**: Content Drive's per-field filters MUST match terms literally on the same reserved set
+ as FR-027, and this MUST be confirmed by test rather than by inspection. The implementation
+ already satisfies this; the requirement exists so a regression would be caught and so #37532's
+ corresponding criterion is demonstrably met.
+- **FR-031**: The all-fields search behaviour MUST remain shared with the Search portlet and the
+ Relationships dialog. FR-027's escaping corrects a defect in that shared path and therefore
+ benefits them too; it MUST NOT be implemented as a Content-Drive-only branch that leaves the
+ shared strategy broken for its other consumers.
+
+### Key Entities
+
+- **Search scope**: Which fields of a document a Content Drive search term is matched against. Two
+ values — *Title* and *All Fields* — with *All Fields* as the default. Lives alongside the search
+ term as part of the drive's filter state, is carried in the address, and is sent with the search
+ request as `filters.searchScope`. Named in full throughout: Content Drive is separately gaining a
+ **browse scope** (#37426) that says *where* you are browsing. The two are independent — a browse
+ scope says where you are, a search scope says how a search reads what is there.
+- **Content Drive search request**: The existing description of what the drive should list —
+ location, term, content types, languages, status, workflow, per-field criteria, sort, paging. Its
+ `filters` object today holds `text` and `filterFolders`, both of which qualify the text search;
+ the search scope joins them as a third, equally text-dependent member.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: On a drive containing a document whose body mentions the search term and a document
+ whose name is the search term, an author in **Title** scope sees only the second — verified as a
+ binary pass on a seeded dataset.
+- **SC-002**: For any term and filter combination **that contains no Lucene reserved character and
+ no consecutive separator**, All Fields results are byte-identical to the results the same drive
+ returns before this change — zero regressions across the search cases covered by the endpoint's
+ test suite. Terms inside the FR-009 carve-out are measured by SC-009 and SC-010 instead, and the
+ set of tests that change is enumerated in the PR rather than left implicit.
+- **SC-003**: On a large dataset, a **Title** search returns its first page faster than the same
+ term in **All Fields** scope, and the before/after comparison is recorded on the issue. The
+ target is a measurable reduction, not a fixed threshold; the comparison itself is the deliverable
+ the issue asks for.
+- **SC-004**: A Content Drive address carrying a search scope reproduces the same narrowed view for
+ a second user 100% of the time, and reload and Back/Forward preserve the search scope in 100% of
+ attempts.
+- **SC-005**: 100% of search requests that omit `filters.searchScope` produce today's results —
+ confirmed by an explicit endpoint test for the omitted field, not only by the explicit all-fields
+ case.
+- **SC-006**: The Asset Picker's search box shows no search scope control and its search behavior is
+ unchanged, confirmed by its existing tests passing without modification.
+- **SC-007**: An author who knows the name of the item they want reaches it from the search box
+ without scrolling past unrelated body matches, on a drive where the all-fields search for the
+ same term returns more than one page.
+- **SC-008**: Every caller of the shared content listing is accounted for by name rather than by a
+ blanket claim, and the three that are not Content Drive or the Asset Picker return identical
+ results before and after the change.
+- **SC-009**: Searching the exact headline reported on customer ticket 39185 —
+ `ABC Bank (XETRA: DBKGn.DB / NYSE: DB) and PSL Launch independent European CLO Total Return Indices`
+ — returns the item, in both search scopes, as a regression test that fails before the change and
+ passes after it.
+- **SC-010**: Every character in the reserved set `\ + - ! ( ) : ^ [ ] " { } ~ * ? | & /` is
+ covered by a test that seeds a title containing it and finds that title by searching for it
+ verbatim — the full set, not a sample, in both search scopes.
+- **SC-011**: A search that fails in a way the front end can itself observe (network or server
+ errors reaching the browser) produces a visible error state in 100% of such attempts, and zero of
+ those attempts render as a successful empty result list. ~~A search whose query fails to
+ execute... in 100% of attempts~~ — **narrowed 2026-09-15** to match FR-029 as amended: an internal
+ `BrowserAPIImpl` execution failure that the front end never observes as an error stays
+ logged-only, exactly as before this feature.
+- **SC-012**: Content Drive's per-field filters pass the same reserved-set coverage as SC-010,
+ confirming #37532's field-filter criterion by test rather than by inspection.
+
+## Legacy Considerations *(dotCMS-specific — mandatory)*
+
+- **Existing behavior touched**: Content Drive's keyword search, and the browsing service that
+ backs it. The browsing service is long-standing, pre-Content-Drive product surface shared with
+ other file-browsing entry points; Content Drive's search endpoint and its front end are recent.
+ The shared search box is also used by the Asset Picker, which is explicitly not in scope. Two
+ callers reach the Content Drive search endpoint (Content Drive and the Asset Picker); three more
+ reach the same underlying listing by other doors (FR-024).
+- **Backward-compatibility expectations**: Strict. All-fields search must be unchanged for every
+ existing caller and every existing address; the search scope is additive and optional at every
+ layer, and its absence must be indistinguishable from today. No existing behavior is deprecated.
+ The all-fields query strategy is shared with the Search portlet and the Relationships dialog and
+ must keep serving them unmodified — the Title scope is a sibling path, not a branch inside the
+ existing one.
+- **Legacy surface deliberately left as-is**: the **Content Search portlet** keeps the behaviour
+ #37532 reports. Its `ContentletAjax.searchContentletsByUser()` field-filter branch
+ (`ContentletAjax.java:874-906`) still builds unescaped per-token clauses and still swallows the
+ parse failure at `:1058`. #37532 frames the legacy construction as something to replace rather
+ than patch, and names Content Drive as where the improved behaviour should land — so this spec
+ improves the replacement, not the thing being replaced. **Consequence to accept consciously**: a
+ user filtering in the Content Search portlet still sees a silent empty result set until Content
+ Drive replaces it. See [Why #37532 lands here](#why-37532-lands-here).
+- **Shared-path defect, shared-path fix**: FR-027 corrects `GlobalSearchAttributeStrategy`, which
+ also serves the Search portlet and the Relationships dialog. Unlike the Title scope — a sibling
+ path deliberately kept out of the shared strategy — the escaping defect is *in* the shared
+ strategy and is fixed there, so every consumer stops mis-parsing reserved characters. This is the
+ one place where this feature intentionally changes behaviour beyond Content Drive, and FR-031
+ states it so it is not mistaken for scope creep.
+- **Known related decisions**: [#36688](https://github.com/dotCMS/core/issues/36688) deliberately
+ replaced a broad leading-wildcard all-fields query with the current strategy, for the same cost
+ reasons argued here — the Title scope must not reintroduce what that issue removed.
+ [#36814](https://github.com/dotCMS/core/issues/36814) tracks search performance at scale and is
+ where SC-003's measurement belongs. **ADR-0018** (Database-First Content Drive Search) is the one
+ ADR this feature touches; its `DB ∪ Index` title routing is addressed explicitly in
+ [ADR-0018 alignment](#adr-0018-alignment) and deferred with reasons, not by omission.
+ [#37426](https://github.com/dotCMS/core/issues/37426) lands a **browse** scope on the same request
+ object; the naming split in FR-023 is agreed between the two specs.
+- **Contract debt deliberately not taken on**: the Content Drive request also lets a caller say the
+ same thing twice (`archived: true` alongside `status: ["ARCHIVED"]`, reconciled in
+ `ContentDriveHelper`), and carries `live` as a boolean that selects a *version* rather than
+ filtering anything; six fields that genuinely are filter-bar chips sit at the top level rather
+ than in `filters`. That cleanup is worth doing while the endpoint still has two callers, but it is
+ a contract change of its own and neither this feature nor #37426 should carry it.
+
+## Assumptions
+
+- **File assets remain findable by name in Title scope, through their title.** Decision 2 excludes
+ `fileName` and `metadata.name`, but a file asset carries a title field that dotCMS keeps in step
+ with the file name — it is seeded from it and rewritten on rename
+ (`FileAssetAPIImpl.java:498`). So searching a file by its name works in Title scope for the
+ ordinary case. The gap is narrow and deliberate: a file whose title has been edited to something
+ other than its file name will not match on the file name. If that gap proves to matter in
+ practice, widening Title scope to cover file names is a follow-up with its own spec, not a
+ silent change here.
+- **Read-your-writes for text search stays where ADR-0018 put it.** The `contentlet.title` column
+ is not reliably populated today and the ADR defers the work that would make it so. This feature
+ assumes it will remain unpopulated for the life of this implementation, and that Title scope's
+ index-only matching is therefore no worse than the all-fields search it sits beside.
+- **The issue's ASCII diagram is the whole design input.** No mock image or design file exists for
+ this control, and none is being waited on. The spec fixes which components are present and how
+ they behave; their appearance is settled during implementation.
+- **The search scope is a front-end-visible concept only for Content Drive.** No other portlet gains
+ it in this work, and the query strategy shared with the Search portlet and the Relationships
+ dialog is left as-is.
+- **"Title" is the label authors understand**, including for folders and files, and does not need to
+ read differently per row type. **"All Fields"** is understood with the help of FR-022's
+ explanation rather than on its own.
+- **The existing address-encoding scheme for Content Drive filters can carry the search scope**
+ without a new mechanism, and an unknown value degrades to the default the same way an unknown
+ status does today.
+- **The measurement in SC-003 needs a dataset large enough for the difference to exceed noise.** The
+ issue does not name one; producing it is part of the work, and the comparison is recorded on the
+ issue rather than in the repository.
+- **No database, index-mapping or content-model change is required.** The search scope selects
+ between two ways of querying what is already indexed, and the escaping fix is a pure string
+ transform over the term before it reaches the index.
+- **Escaping the reserved set does not degrade the matching #36688 tuned.** The characters are made
+ literal, not removed, so the mandatory catchall prefix gate and the boost structure keep the shape
+ that issue settled. Terms free of reserved characters produce a byte-identical query, which is
+ what makes SC-002's narrowed promise measurable.
+- **A visible error state is reachable from where the failure happens.** FR-029 assumes the drive
+ can distinguish "query failed" from "nothing matched" and carry that to the UI. Today the
+ distinction is destroyed at `BrowserAPIImpl:893-895`, which returns an empty set for both; how the
+ signal is carried out is an implementation decision, but the two cases must stop being the same
+ value.
+- **Closing #37532 on this work is the team's call, not this spec's.** The spec delivers every one
+ of its acceptance criteria that applies to Content Drive, and states plainly which two do not.
+ Whether that is enough to close the issue against the customer ticket is a decision for the issue
+ owner.