#!/usr/bin/env node
/**
* Test: Content Search Scope by Content Type
*
* Validates:
* 1. Search is correctly scoped to a specific content type
* 2. Filters ($contains, $eq, $in) work within content types
* 3. Cross-type queries don't leak data between content types
* 4. Pagination and sort within scoped searches
* 5. Schema-aware filtering (only valid fields accepted)
* 6. Population of relations within search scope
*
* Prerequisites:
* - Running Strapi 5.x with MCP plugin
* - At least two collection content types
* - STRAPI_ADMIN_EMAIL / STRAPI_ADMIN_PASSWORD env vars
*/
import {
requireStrapi,
section,
pass,
fail,
skip,
assert,
summary,
getSuperAdminToken,
McpTestClient,
} from './helpers.js';
async function main() {
console.log('\x1b[1m=== Test: Content Search Scope by Content Type ===\x1b[0m');
await requireStrapi();
// ─── Setup ─────────────────────────────────────────────────────────
section('Setup');
const adminToken = await getSuperAdminToken();
if (!adminToken) {
fail('Super admin login', 'Cannot login');
summary();
process.exit(1);
}
pass('Super admin login');
const mcp = new McpTestClient(adminToken);
await mcp.initialize();
pass('MCP session initialized');
// Discover content types
const typesRes = await mcp.callTool('list_content_types', {});
const types = McpTestClient.parseResult(typesRes) || [];
const collections = types.filter((t) => t.kind === 'collectionType');
const singleTypes = types.filter((t) => t.kind === 'singleType');
assert(collections.length > 0, `Found ${collections.length} collection type(s)`, 'No collections found');
console.log(` Content types: ${types.map((t) => t.uid).join(', ')}`);
// Pick two different collection types for cross-type isolation test
const typeA = collections[0];
const typeB = collections.length > 1 ? collections[1] : null;
// ─── Test: Search within a single content type ─────────────────────
section('Search Within Single Content Type');
const searchTag = `search-test-${Date.now()}`;
const createdIds = [];
try {
// Create tagged entries in typeA
for (let i = 0; i < 3; i++) {
const createRes = await mcp.callTool('create_entry', {
contentType: typeA.uid,
data: JSON.stringify({ title: `${searchTag} Item ${i + 1}` }),
});
if (!McpTestClient.isError(createRes)) {
const entry = McpTestClient.parseResult(createRes);
createdIds.push({ uid: typeA.uid, documentId: entry.documentId });
}
}
assert(createdIds.length === 3, `Created ${createdIds.length} test entries in ${typeA.uid}`, 'Failed to create all test entries');
// Search with $contains filter
const searchRes = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $contains: searchTag } }),
});
const searchData = McpTestClient.parseResult(searchRes);
assert(
searchData?.data?.length >= 3,
`$contains filter found ${searchData?.data?.length} entries matching tag`,
`Expected >= 3, got ${searchData?.data?.length}`
);
// Search with exact match
const exactRes = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $eq: `${searchTag} Item 2` } }),
});
const exactData = McpTestClient.parseResult(exactRes);
assert(
exactData?.data?.length === 1,
`$eq filter found exactly 1 entry`,
`Expected 1, got ${exactData?.data?.length}`
);
} catch (err) {
fail('Search within content type', err.message);
}
// ─── Test: Cross-type isolation ────────────────────────────────────
section('Cross-Type Isolation');
if (typeB) {
try {
// Search for the tag in typeB — should find nothing
const crossRes = await mcp.callTool('get_entries', {
contentType: typeB.uid,
filters: JSON.stringify({ title: { $contains: searchTag } }),
});
const crossData = McpTestClient.parseResult(crossRes);
if (!McpTestClient.isError(crossRes)) {
assert(
crossData?.data?.length === 0,
`Tag "${searchTag}" not found in ${typeB.uid} (cross-type isolation)`,
`Found ${crossData?.data?.length} entries in wrong content type`
);
} else {
// If the field doesn't exist in typeB, that's also valid isolation
pass(`${typeB.uid} does not have "title" field (structural isolation)`);
}
} catch (err) {
fail('Cross-type isolation', err.message);
}
} else {
skip('Cross-type isolation', 'Only one collection type available');
}
// ─── Test: Pagination within scoped search ─────────────────────────
section('Pagination Within Search Scope');
try {
// Request page 1 with pageSize 2
const page1Res = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $contains: searchTag } }),
pageSize: 2,
page: 1,
});
const page1Data = McpTestClient.parseResult(page1Res);
assert(
page1Data?.data?.length === 2,
`Page 1 with pageSize=2 returns 2 entries`,
`Expected 2, got ${page1Data?.data?.length}`
);
// Request page 2
const page2Res = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $contains: searchTag } }),
pageSize: 2,
page: 2,
});
const page2Data = McpTestClient.parseResult(page2Res);
assert(
page2Data?.data?.length >= 1,
`Page 2 returns remaining entries (${page2Data?.data?.length})`,
'Page 2 returned no entries'
);
// Verify no overlap between pages
const page1Ids = (page1Data?.data || []).map((e) => e.documentId);
const page2Ids = (page2Data?.data || []).map((e) => e.documentId);
const overlap = page1Ids.filter((id) => page2Ids.includes(id));
assert(overlap.length === 0, 'No overlap between page 1 and page 2', `Overlap: ${overlap.join(', ')}`);
} catch (err) {
fail('Pagination within search', err.message);
}
// ─── Test: Sort within scoped search ───────────────────────────────
section('Sort Within Search Scope');
try {
// Sort ascending by title
const ascRes = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $contains: searchTag } }),
sort: 'title:asc',
});
const ascData = McpTestClient.parseResult(ascRes);
if (ascData?.data?.length >= 2) {
const titles = ascData.data.map((e) => e.title);
const isSorted = titles.every((t, i) => i === 0 || t >= titles[i - 1]);
assert(isSorted, 'Entries sorted ascending by title', `Order: ${titles.join(', ')}`);
} else {
skip('Sort ascending', 'Not enough entries to verify sort');
}
// Sort descending
const descRes = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $contains: searchTag } }),
sort: 'title:desc',
});
const descData = McpTestClient.parseResult(descRes);
if (descData?.data?.length >= 2) {
const titles = descData.data.map((e) => e.title);
const isSortedDesc = titles.every((t, i) => i === 0 || t <= titles[i - 1]);
assert(isSortedDesc, 'Entries sorted descending by title', `Order: ${titles.join(', ')}`);
}
} catch (err) {
fail('Sort within search', err.message);
}
// ─── Test: Invalid content type search ─────────────────────────────
section('Invalid Content Type Handling');
try {
const invalidRes = await mcp.callTool('get_entries', {
contentType: 'api::nonexistent.nonexistent',
});
assert(
McpTestClient.isError(invalidRes),
'Search on nonexistent content type returns error',
'Expected error for invalid content type'
);
} catch (err) {
// An exception is also acceptable
pass('Invalid content type throws error');
}
// ─── Test: Schema-aware field access ───────────────────────────────
section('Schema-Aware Field Access');
try {
// Get schema for typeA
const schemaRes = await mcp.callTool('get_content_type_schema', { contentType: typeA.uid });
const schema = McpTestClient.parseResult(schemaRes);
assert(schema?.attributes?.length > 0, `Schema has ${schema?.attributes?.length} attributes`, 'No attributes');
const fieldNames = schema.attributes.map((a) => a.name);
console.log(` Fields: ${fieldNames.join(', ')}`);
// Search with a field that exists
if (fieldNames.includes('title')) {
const validRes = await mcp.callTool('get_entries', {
contentType: typeA.uid,
filters: JSON.stringify({ title: { $contains: 'test' } }),
pageSize: 1,
});
assert(!McpTestClient.isError(validRes), 'Filter on valid field "title" succeeds', 'Filter failed');
}
} catch (err) {
fail('Schema-aware field access', err.message);
}
// ─── Test: Single type search scope ────────────────────────────────
section('Single Type Search Scope');
if (singleTypes.length > 0) {
try {
const st = singleTypes[0];
const stRes = await mcp.callTool('get_single_type', { contentType: st.uid });
if (!McpTestClient.isError(stRes)) {
const stData = McpTestClient.parseResult(stRes);
pass(`Single type ${st.uid} returned ${stData ? 'data' : 'null'} (scoped correctly)`);
} else {
pass(`Single type ${st.uid} returned error (may not have entry yet)`);
}
} catch (err) {
fail('Single type search', err.message);
}
} else {
skip('Single type search', 'No single types defined');
}
// ─── Test: Population within search ────────────────────────────────
section('Relation Population in Search');
try {
// Get schema to find relation fields
const schemaRes = await mcp.callTool('get_content_type_schema', { contentType: typeA.uid });
const schema = McpTestClient.parseResult(schemaRes);
const relations = schema?.attributes?.filter((a) => a.type === 'relation') || [];
if (relations.length > 0) {
const relField = relations[0].name;
const popRes = await mcp.callTool('get_entries', {
contentType: typeA.uid,
populate: relField,
pageSize: 1,
});
assert(!McpTestClient.isError(popRes), `Populate relation "${relField}" in search succeeds`, 'Populate failed');
} else {
skip('Relation population', `${typeA.uid} has no relation fields`);
}
} catch (err) {
fail('Relation population in search', err.message);
}
// ─── Cleanup ───────────────────────────────────────────────────────
section('Cleanup');
let cleanedUp = 0;
for (const { uid, documentId } of createdIds) {
try {
await mcp.callTool('delete_entry', { contentType: uid, documentId });
cleanedUp++;
} catch {
// ignore
}
}
pass(`Cleaned up ${cleanedUp}/${createdIds.length} test entries`);
await mcp.close();
const results = summary();
process.exit(results.failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});