Merge branch 'worktree-agent-a54bb856'
# Conflicts: # server/models/wikiArticle.js
This commit is contained in:
commit
4a475ca5ba
7 changed files with 450 additions and 6 deletions
|
|
@ -28,3 +28,6 @@ BASE_URL=http://localhost:3000
|
||||||
OIDC_CLIENT_ID=outline-wiki
|
OIDC_CLIENT_ID=outline-wiki
|
||||||
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
|
|
||||||
|
# Outline Wiki Integration
|
||||||
|
OUTLINE_API_KEY=
|
||||||
|
|
@ -87,6 +87,7 @@ export default defineNuxtConfig({
|
||||||
oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki",
|
||||||
oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "",
|
oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "",
|
||||||
oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "",
|
oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "",
|
||||||
|
outlineApiKey: process.env.OUTLINE_API_KEY || "",
|
||||||
|
|
||||||
// Public keys (available on client-side)
|
// Public keys (available on client-side)
|
||||||
public: {
|
public: {
|
||||||
|
|
|
||||||
84
server/api/admin/wiki/sync.post.js
Normal file
84
server/api/admin/wiki/sync.post.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import WikiArticle from '../../../models/wikiArticle.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
import { fetchAllDocuments, extractSummary } from '../../../utils/outline.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
// Fetch ALL documents first — if this fails, no DB changes happen
|
||||||
|
let documents
|
||||||
|
try {
|
||||||
|
documents = await fetchAllDocuments()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[wiki-sync] Outline fetch failed:', err)
|
||||||
|
throw createError({
|
||||||
|
statusCode: err.statusCode || 502,
|
||||||
|
statusMessage: err.statusMessage || 'Failed to fetch documents from Outline'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const fetchedOutlineIds = new Set(documents.map((doc) => doc.id))
|
||||||
|
|
||||||
|
// Get all existing articles for comparison
|
||||||
|
const existing = await WikiArticle.find({}, 'outlineId publishedAt')
|
||||||
|
const existingByOutlineId = new Map(
|
||||||
|
existing.map((a) => [a.outlineId, a])
|
||||||
|
)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
let deleted = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
// Upsert each fetched document
|
||||||
|
for (const doc of documents) {
|
||||||
|
try {
|
||||||
|
const articleData = {
|
||||||
|
title: doc.title,
|
||||||
|
collection: doc.collection?.name || null,
|
||||||
|
url: doc.url,
|
||||||
|
summary: extractSummary(doc.text),
|
||||||
|
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt),
|
||||||
|
permission: doc.permission || null,
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await WikiArticle.findOneAndUpdate(
|
||||||
|
{ outlineId: doc.id },
|
||||||
|
{ $set: articleData },
|
||||||
|
{ upsert: true, new: true, rawResult: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.lastErrorObject?.updatedExisting) {
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err)
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete articles no longer in Outline
|
||||||
|
for (const [outlineId, article] of existingByOutlineId) {
|
||||||
|
if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) {
|
||||||
|
try {
|
||||||
|
await WikiArticle.findOneAndUpdate(
|
||||||
|
{ outlineId },
|
||||||
|
{ $set: { publishedAt: null, lastSyncedAt: new Date() } }
|
||||||
|
)
|
||||||
|
deleted++
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err)
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, deleted, errors }
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import mongoose from "mongoose";
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
const wikiArticleSchema = new mongoose.Schema(
|
const wikiArticleSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
|
|
@ -11,10 +11,10 @@ const wikiArticleSchema = new mongoose.Schema(
|
||||||
publishedAt: Date,
|
publishedAt: Date,
|
||||||
permission: String,
|
permission: String,
|
||||||
lastSyncedAt: Date,
|
lastSyncedAt: Date,
|
||||||
outlineUpdatedAt: Date,
|
outlineUpdatedAt: Date
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
)
|
||||||
|
|
||||||
export default mongoose.models.WikiArticle ||
|
export default mongoose.models.WikiArticle ||
|
||||||
mongoose.model("WikiArticle", wikiArticleSchema);
|
mongoose.model('WikiArticle', wikiArticleSchema)
|
||||||
|
|
|
||||||
67
server/utils/outline.js
Normal file
67
server/utils/outline.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
const OUTLINE_API_BASE = 'https://wiki.ghostguild.org/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip HTML tags and truncate to 200 characters at a word boundary.
|
||||||
|
* If the stripped text is <= 200 chars, returns it as-is.
|
||||||
|
*/
|
||||||
|
export function extractSummary(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const stripped = text.replace(/<[^>]*>/g, '').trim()
|
||||||
|
|
||||||
|
if (stripped.length <= 200) return stripped
|
||||||
|
|
||||||
|
const truncated = stripped.slice(0, 200)
|
||||||
|
const lastSpace = truncated.lastIndexOf(' ')
|
||||||
|
|
||||||
|
// If no space found at all, return the full 200 chars (single long word)
|
||||||
|
if (lastSpace <= 0) return truncated
|
||||||
|
|
||||||
|
return truncated.slice(0, lastSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all documents from Outline wiki, paginating through all pages.
|
||||||
|
* Throws on any page failure — caller is responsible for abort logic.
|
||||||
|
*/
|
||||||
|
export async function fetchAllDocuments() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const apiKey = config.outlineApiKey
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Outline API key not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = []
|
||||||
|
let path = '/documents.list'
|
||||||
|
|
||||||
|
while (path) {
|
||||||
|
const response = await fetch(`${OUTLINE_API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ limit: 25 })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error(`[outline] POST ${path} ${response.status} ${errorText}`)
|
||||||
|
throw createError({
|
||||||
|
statusCode: response.status,
|
||||||
|
statusMessage: 'Outline API error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
documents.push(...(data.data || []))
|
||||||
|
|
||||||
|
path = data.pagination?.nextPath || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,9 @@ const adminRoutes = {
|
||||||
'alerts/dismiss.post.js',
|
'alerts/dismiss.post.js',
|
||||||
'alerts/dismissed.get.js',
|
'alerts/dismissed.get.js',
|
||||||
'alerts/restore.post.js'
|
'alerts/restore.post.js'
|
||||||
|
],
|
||||||
|
'admin/wiki/': [
|
||||||
|
'wiki/sync.post.js'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +75,10 @@ const businessLogicPatterns = [
|
||||||
'computeAllAlerts(',
|
'computeAllAlerts(',
|
||||||
'AdminAlertDismissal.findOneAndUpdate',
|
'AdminAlertDismissal.findOneAndUpdate',
|
||||||
'AdminAlertDismissal.find',
|
'AdminAlertDismissal.find',
|
||||||
'AdminAlertDismissal.deleteMany'
|
'AdminAlertDismissal.deleteMany',
|
||||||
|
'WikiArticle.find',
|
||||||
|
'WikiArticle.findOneAndUpdate',
|
||||||
|
'fetchAllDocuments('
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('Admin endpoint auth guards', () => {
|
describe('Admin endpoint auth guards', () => {
|
||||||
|
|
|
||||||
283
tests/server/api/wiki-sync.test.js
Normal file
283
tests/server/api/wiki-sync.test.js
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies before imports
|
||||||
|
vi.mock('../../../server/models/wikiArticle.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn(),
|
||||||
|
findOneAndUpdate: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
requireAdmin: vi.fn()
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/utils/outline.js', () => ({
|
||||||
|
fetchAllDocuments: vi.fn(),
|
||||||
|
extractSummary: vi.fn((text) => text || '')
|
||||||
|
}))
|
||||||
|
|
||||||
|
import WikiArticle from '../../../server/models/wikiArticle.js'
|
||||||
|
import { requireAdmin } from '../../../server/utils/auth.js'
|
||||||
|
import { fetchAllDocuments, extractSummary } from '../../../server/utils/outline.js'
|
||||||
|
import syncHandler from '../../../server/api/admin/wiki/sync.post.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
// Also test extractSummary directly (unmocked)
|
||||||
|
import { extractSummary as realExtractSummary } from '../../../server/utils/outline.js'
|
||||||
|
|
||||||
|
function makeOutlineDoc(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'doc-1',
|
||||||
|
title: 'Test Article',
|
||||||
|
url: '/doc/test-article',
|
||||||
|
text: 'Some article content',
|
||||||
|
publishedAt: '2026-01-15T00:00:00Z',
|
||||||
|
createdAt: '2026-01-10T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-15T00:00:00Z',
|
||||||
|
permission: 'read',
|
||||||
|
collection: { name: 'General' },
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wiki sync endpoint', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
WikiArticle.find.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires admin auth (403)', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Admin access required' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(syncHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Admin access required'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requireAdmin).toHaveBeenCalledWith(event)
|
||||||
|
expect(fetchAllDocuments).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts new articles from Outline', async () => {
|
||||||
|
const docs = [
|
||||||
|
makeOutlineDoc({ id: 'doc-1', title: 'First Article' }),
|
||||||
|
makeOutlineDoc({ id: 'doc-2', title: 'Second Article' })
|
||||||
|
]
|
||||||
|
fetchAllDocuments.mockResolvedValue(docs)
|
||||||
|
WikiArticle.find.mockResolvedValue([])
|
||||||
|
WikiArticle.findOneAndUpdate.mockResolvedValue({
|
||||||
|
lastErrorObject: { updatedExisting: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await syncHandler(event)
|
||||||
|
|
||||||
|
expect(result.created).toBe(2)
|
||||||
|
expect(result.updated).toBe(0)
|
||||||
|
expect(result.deleted).toBe(0)
|
||||||
|
expect(result.errors).toBe(0)
|
||||||
|
expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledTimes(2)
|
||||||
|
expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ outlineId: 'doc-1' },
|
||||||
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({ title: 'First Article' })
|
||||||
|
}),
|
||||||
|
{ upsert: true, new: true, rawResult: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates changed titles and URLs', async () => {
|
||||||
|
const docs = [
|
||||||
|
makeOutlineDoc({ id: 'doc-1', title: 'Updated Title', url: '/doc/new-url' })
|
||||||
|
]
|
||||||
|
fetchAllDocuments.mockResolvedValue(docs)
|
||||||
|
WikiArticle.find.mockResolvedValue([
|
||||||
|
{ outlineId: 'doc-1', publishedAt: new Date('2026-01-15') }
|
||||||
|
])
|
||||||
|
WikiArticle.findOneAndUpdate.mockResolvedValue({
|
||||||
|
lastErrorObject: { updatedExisting: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await syncHandler(event)
|
||||||
|
|
||||||
|
expect(result.updated).toBe(1)
|
||||||
|
expect(result.created).toBe(0)
|
||||||
|
expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ outlineId: 'doc-1' },
|
||||||
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({
|
||||||
|
title: 'Updated Title',
|
||||||
|
url: '/doc/new-url'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ upsert: true, new: true, rawResult: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('soft-deletes removed articles (publishedAt set to null)', async () => {
|
||||||
|
fetchAllDocuments.mockResolvedValue([
|
||||||
|
makeOutlineDoc({ id: 'doc-1' })
|
||||||
|
])
|
||||||
|
WikiArticle.find.mockResolvedValue([
|
||||||
|
{ outlineId: 'doc-1', publishedAt: new Date('2026-01-15') },
|
||||||
|
{ outlineId: 'doc-2', publishedAt: new Date('2026-01-10') }
|
||||||
|
])
|
||||||
|
WikiArticle.findOneAndUpdate.mockResolvedValue({
|
||||||
|
lastErrorObject: { updatedExisting: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await syncHandler(event)
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(1)
|
||||||
|
// The second call should be the soft-delete for doc-2
|
||||||
|
const softDeleteCall = WikiArticle.findOneAndUpdate.mock.calls.find(
|
||||||
|
(call) => call[0].outlineId === 'doc-2'
|
||||||
|
)
|
||||||
|
expect(softDeleteCall).toBeTruthy()
|
||||||
|
expect(softDeleteCall[1].$set.publishedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores re-published articles', async () => {
|
||||||
|
const docs = [
|
||||||
|
makeOutlineDoc({
|
||||||
|
id: 'doc-1',
|
||||||
|
publishedAt: '2026-02-01T00:00:00Z'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
fetchAllDocuments.mockResolvedValue(docs)
|
||||||
|
// doc-1 was previously soft-deleted (publishedAt: null)
|
||||||
|
WikiArticle.find.mockResolvedValue([
|
||||||
|
{ outlineId: 'doc-1', publishedAt: null }
|
||||||
|
])
|
||||||
|
WikiArticle.findOneAndUpdate.mockResolvedValue({
|
||||||
|
lastErrorObject: { updatedExisting: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await syncHandler(event)
|
||||||
|
|
||||||
|
expect(result.updated).toBe(1)
|
||||||
|
expect(result.deleted).toBe(0)
|
||||||
|
// The upsert should set publishedAt from the Outline document
|
||||||
|
const upsertCall = WikiArticle.findOneAndUpdate.mock.calls.find(
|
||||||
|
(call) => call[0].outlineId === 'doc-1'
|
||||||
|
)
|
||||||
|
expect(upsertCall[1].$set.publishedAt).toEqual(new Date('2026-02-01T00:00:00Z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts on mid-pagination API error with no partial writes', async () => {
|
||||||
|
fetchAllDocuments.mockRejectedValue(
|
||||||
|
createError({ statusCode: 502, statusMessage: 'Outline API error' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(syncHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 502
|
||||||
|
})
|
||||||
|
|
||||||
|
// No DB writes should have occurred
|
||||||
|
expect(WikiArticle.findOneAndUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(WikiArticle.find).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns counts in response', async () => {
|
||||||
|
const docs = [
|
||||||
|
makeOutlineDoc({ id: 'doc-1' }),
|
||||||
|
makeOutlineDoc({ id: 'doc-2' }),
|
||||||
|
makeOutlineDoc({ id: 'doc-3' })
|
||||||
|
]
|
||||||
|
fetchAllDocuments.mockResolvedValue(docs)
|
||||||
|
WikiArticle.find.mockResolvedValue([
|
||||||
|
{ outlineId: 'doc-1', publishedAt: new Date() },
|
||||||
|
{ outlineId: 'doc-4', publishedAt: new Date() }
|
||||||
|
])
|
||||||
|
// doc-1 exists (update), doc-2 and doc-3 are new (create), doc-4 is gone (delete)
|
||||||
|
WikiArticle.findOneAndUpdate
|
||||||
|
.mockResolvedValueOnce({ lastErrorObject: { updatedExisting: true } }) // doc-1 update
|
||||||
|
.mockResolvedValueOnce({ lastErrorObject: { updatedExisting: false } }) // doc-2 create
|
||||||
|
.mockResolvedValueOnce({ lastErrorObject: { updatedExisting: false } }) // doc-3 create
|
||||||
|
.mockResolvedValue({}) // doc-4 soft-delete
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/sync'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await syncHandler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
created: 2,
|
||||||
|
updated: 1,
|
||||||
|
deleted: 1,
|
||||||
|
errors: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test extractSummary directly — reimport unmocked
|
||||||
|
describe('extractSummary', () => {
|
||||||
|
// We need the real implementation, so we dynamically import it
|
||||||
|
// to bypass the module mock above
|
||||||
|
let realExtract
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mod = await vi.importActual('../../../server/utils/outline.js')
|
||||||
|
realExtract = mod.extractSummary
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for falsy input', () => {
|
||||||
|
expect(realExtract('')).toBe('')
|
||||||
|
expect(realExtract(null)).toBe('')
|
||||||
|
expect(realExtract(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips HTML tags', () => {
|
||||||
|
expect(realExtract('<p>Hello <strong>world</strong></p>')).toBe('Hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns text as-is when <= 200 chars after stripping', () => {
|
||||||
|
const short = 'A short summary.'
|
||||||
|
expect(realExtract(short)).toBe(short)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates at word boundary without mid-word cuts', () => {
|
||||||
|
// Build a string that is longer than 200 chars
|
||||||
|
const words = 'word '.repeat(50).trim() // 249 chars: "word word word..."
|
||||||
|
const result = realExtract(words)
|
||||||
|
expect(result.length).toBeLessThanOrEqual(200)
|
||||||
|
// Should not end mid-word
|
||||||
|
expect(result).not.toMatch(/\S$\S/)
|
||||||
|
// Should end with a complete word
|
||||||
|
expect(result.endsWith('word')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue