Apply RLS context at task service boundaries, harden orchestrator/web integration and session startup behavior, re-enable targeted frontend tests, and lock vulnerable transitive dependencies so QA and security gates pass cleanly.
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import { PrismaClient } from "@prisma/client";
|
|
|
|
/**
|
|
* Check if fulltext search trigger is properly configured in the database.
|
|
* Returns true if the trigger function exists (meaning the migration was applied).
|
|
*/
|
|
async function isFulltextSearchConfigured(prisma: PrismaClient): Promise<boolean> {
|
|
try {
|
|
const result = await prisma.$queryRaw<{ exists: boolean }[]>`
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM pg_proc
|
|
WHERE proname = 'knowledge_entries_search_vector_update'
|
|
) as exists
|
|
`;
|
|
return result[0]?.exists ?? false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Integration tests for PostgreSQL full-text search setup
|
|
* Tests the tsvector column, GIN index, and automatic trigger
|
|
*
|
|
* NOTE: These tests require a real database connection.
|
|
* Skip when DATABASE_URL is not set. Tests that require the trigger/index
|
|
* will be skipped if the database migration hasn't been applied.
|
|
*/
|
|
const shouldRunDbIntegrationTests =
|
|
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
|
|
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
|
|
|
|
describeFn("Full-Text Search Setup (Integration)", () => {
|
|
let prisma: PrismaClient;
|
|
let testWorkspaceId: string;
|
|
let testUserId: string;
|
|
let fulltextConfigured = false;
|
|
|
|
beforeAll(async () => {
|
|
prisma = new PrismaClient();
|
|
await prisma.$connect();
|
|
|
|
// Check if fulltext search is properly configured (trigger exists)
|
|
fulltextConfigured = await isFulltextSearchConfigured(prisma);
|
|
if (!fulltextConfigured) {
|
|
console.warn(
|
|
"Skipping fulltext-search trigger/index tests: " +
|
|
"PostgreSQL trigger function not found. " +
|
|
"Run the full migration to enable these tests."
|
|
);
|
|
}
|
|
|
|
// Create test workspace
|
|
const workspace = await prisma.workspace.create({
|
|
data: {
|
|
name: "Test Workspace",
|
|
owner: {
|
|
create: {
|
|
email: `test-fts-${Date.now()}@example.com`,
|
|
name: "Test User",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
testWorkspaceId = workspace.id;
|
|
testUserId = workspace.ownerId;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup
|
|
if (testWorkspaceId) {
|
|
await prisma.knowledgeEntry.deleteMany({
|
|
where: { workspaceId: testWorkspaceId },
|
|
});
|
|
await prisma.workspace.delete({
|
|
where: { id: testWorkspaceId },
|
|
});
|
|
}
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("tsvector column", () => {
|
|
it("should have search_vector column in knowledge_entries table", async () => {
|
|
// Query to check if column exists (always runs - validates schema)
|
|
const result = await prisma.$queryRaw<{ column_name: string; data_type: string }[]>`
|
|
SELECT column_name, data_type
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'knowledge_entries'
|
|
AND column_name = 'search_vector'
|
|
`;
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].column_name).toBe("search_vector");
|
|
expect(result[0].data_type).toBe("tsvector");
|
|
});
|
|
|
|
it("should automatically populate search_vector on insert", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: trigger not configured");
|
|
return;
|
|
}
|
|
|
|
const entry = await prisma.knowledgeEntry.create({
|
|
data: {
|
|
workspaceId: testWorkspaceId,
|
|
slug: "auto-populate-test",
|
|
title: "PostgreSQL Full-Text Search",
|
|
content: "This is a test of the automatic trigger functionality.",
|
|
summary: "Testing automatic population",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
});
|
|
|
|
// Query raw to check search_vector was populated
|
|
const result = await prisma.$queryRaw<{ id: string; search_vector: string | null }[]>`
|
|
SELECT id, search_vector::text
|
|
FROM knowledge_entries
|
|
WHERE id = ${entry.id}::uuid
|
|
`;
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].search_vector).not.toBeNull();
|
|
// Verify 'postgresql' appears in title (weight A)
|
|
expect(result[0].search_vector).toContain("'postgresql':1A");
|
|
// Verify 'search' appears in both title (A) and content (C)
|
|
expect(result[0].search_vector).toContain("'search':5A");
|
|
});
|
|
|
|
it("should automatically update search_vector on update", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: trigger not configured");
|
|
return;
|
|
}
|
|
|
|
const entry = await prisma.knowledgeEntry.create({
|
|
data: {
|
|
workspaceId: testWorkspaceId,
|
|
slug: "auto-update-test",
|
|
title: "Original Title",
|
|
content: "Original content",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
});
|
|
|
|
// Update the entry
|
|
await prisma.knowledgeEntry.update({
|
|
where: { id: entry.id },
|
|
data: {
|
|
title: "Updated Elasticsearch Title",
|
|
content: "Updated content with Elasticsearch",
|
|
},
|
|
});
|
|
|
|
// Check search_vector was updated
|
|
const result = await prisma.$queryRaw<{ id: string; search_vector: string | null }[]>`
|
|
SELECT id, search_vector::text
|
|
FROM knowledge_entries
|
|
WHERE id = ${entry.id}::uuid
|
|
`;
|
|
|
|
expect(result).toHaveLength(1);
|
|
// Verify 'elasticsearch' appears in both title (A) and content (C)
|
|
// PostgreSQL combines positions: '2A,7C' means position 2 in title (A) and position 7 in content (C)
|
|
expect(result[0].search_vector).toContain("'elasticsearch':2A,7C");
|
|
expect(result[0].search_vector).not.toContain("'original'");
|
|
});
|
|
|
|
it("should include summary in search_vector with weight B", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: trigger not configured");
|
|
return;
|
|
}
|
|
|
|
const entry = await prisma.knowledgeEntry.create({
|
|
data: {
|
|
workspaceId: testWorkspaceId,
|
|
slug: "summary-weight-test",
|
|
title: "Title Word",
|
|
content: "Content word",
|
|
summary: "Summary keyword here",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
});
|
|
|
|
const result = await prisma.$queryRaw<{ id: string; search_vector: string | null }[]>`
|
|
SELECT id, search_vector::text
|
|
FROM knowledge_entries
|
|
WHERE id = ${entry.id}::uuid
|
|
`;
|
|
|
|
expect(result).toHaveLength(1);
|
|
// Summary should have weight B - 'keyword' appears in summary
|
|
expect(result[0].search_vector).toContain("'keyword':4B");
|
|
});
|
|
|
|
it("should handle null summary gracefully", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: trigger not configured");
|
|
return;
|
|
}
|
|
|
|
const entry = await prisma.knowledgeEntry.create({
|
|
data: {
|
|
workspaceId: testWorkspaceId,
|
|
slug: "null-summary-test",
|
|
title: "Title without summary",
|
|
content: "Content without summary",
|
|
summary: null,
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
});
|
|
|
|
const result = await prisma.$queryRaw<{ id: string; search_vector: string | null }[]>`
|
|
SELECT id, search_vector::text
|
|
FROM knowledge_entries
|
|
WHERE id = ${entry.id}::uuid
|
|
`;
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].search_vector).not.toBeNull();
|
|
// Verify 'titl' (stemmed from 'title') appears with weight A
|
|
expect(result[0].search_vector).toContain("'titl':1A");
|
|
// Verify 'content' appears with weight C
|
|
expect(result[0].search_vector).toContain("'content':4C");
|
|
});
|
|
});
|
|
|
|
describe("GIN index", () => {
|
|
it("should have GIN index on search_vector column", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: GIN index not configured");
|
|
return;
|
|
}
|
|
|
|
const result = await prisma.$queryRaw<{ indexname: string; indexdef: string }[]>`
|
|
SELECT indexname, indexdef
|
|
FROM pg_indexes
|
|
WHERE tablename = 'knowledge_entries'
|
|
AND indexname = 'knowledge_entries_search_vector_idx'
|
|
`;
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].indexdef).toContain("gin");
|
|
expect(result[0].indexdef).toContain("search_vector");
|
|
});
|
|
});
|
|
|
|
describe("search performance", () => {
|
|
it("should perform fast searches using the GIN index", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: fulltext search not configured");
|
|
return;
|
|
}
|
|
|
|
// Create multiple entries
|
|
const entries = Array.from({ length: 10 }, (_, i) => ({
|
|
workspaceId: testWorkspaceId,
|
|
slug: `perf-test-${i}`,
|
|
title: `Performance Test ${i}`,
|
|
content: i % 2 === 0 ? "Contains database keyword" : "No keyword here",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
}));
|
|
|
|
await prisma.knowledgeEntry.createMany({
|
|
data: entries,
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Search using the precomputed search_vector
|
|
const results = await prisma.$queryRaw<{ id: string; title: string }[]>`
|
|
SELECT id, title
|
|
FROM knowledge_entries
|
|
WHERE workspace_id = ${testWorkspaceId}::uuid
|
|
AND search_vector @@ plainto_tsquery('english', 'database')
|
|
ORDER BY ts_rank(search_vector, plainto_tsquery('english', 'database')) DESC
|
|
`;
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// Should be fast with index (< 100ms for small dataset)
|
|
expect(duration).toBeLessThan(100);
|
|
});
|
|
|
|
it("should rank results by relevance using weighted fields", async () => {
|
|
if (!fulltextConfigured) {
|
|
console.log("Skipping: fulltext search not configured");
|
|
return;
|
|
}
|
|
|
|
// Create entries with keyword in different positions
|
|
await prisma.knowledgeEntry.createMany({
|
|
data: [
|
|
{
|
|
workspaceId: testWorkspaceId,
|
|
slug: "rank-title",
|
|
title: "Redis caching strategies",
|
|
content: "Various approaches to caching",
|
|
summary: "Overview of strategies",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
{
|
|
workspaceId: testWorkspaceId,
|
|
slug: "rank-summary",
|
|
title: "Database optimization",
|
|
content: "Performance tuning",
|
|
summary: "Redis is mentioned in summary",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
{
|
|
workspaceId: testWorkspaceId,
|
|
slug: "rank-content",
|
|
title: "Performance guide",
|
|
content: "Use Redis for better performance",
|
|
summary: "Best practices",
|
|
createdBy: testUserId,
|
|
updatedBy: testUserId,
|
|
},
|
|
],
|
|
});
|
|
|
|
const results = await prisma.$queryRaw<{ slug: string; rank: number }[]>`
|
|
SELECT slug, ts_rank(search_vector, plainto_tsquery('english', 'redis')) AS rank
|
|
FROM knowledge_entries
|
|
WHERE workspace_id = ${testWorkspaceId}::uuid
|
|
AND search_vector @@ plainto_tsquery('english', 'redis')
|
|
ORDER BY rank DESC
|
|
`;
|
|
|
|
expect(results.length).toBe(3);
|
|
// Title match should rank highest (weight A)
|
|
expect(results[0].slug).toBe("rank-title");
|
|
// Summary should rank second (weight B)
|
|
expect(results[1].slug).toBe("rank-summary");
|
|
// Content should rank third (weight C)
|
|
expect(results[2].slug).toBe("rank-content");
|
|
});
|
|
});
|
|
});
|