Merge branch 'worktree-agent-a54bb856'

# Conflicts:
#	server/models/wikiArticle.js
This commit is contained in:
Jennie Robinson Faber 2026-04-09 22:34:09 +01:00
commit 4a475ca5ba
7 changed files with 450 additions and 6 deletions

View file

@ -45,6 +45,9 @@ const adminRoutes = {
'alerts/dismiss.post.js',
'alerts/dismissed.get.js',
'alerts/restore.post.js'
],
'admin/wiki/': [
'wiki/sync.post.js'
]
}
@ -72,7 +75,10 @@ const businessLogicPatterns = [
'computeAllAlerts(',
'AdminAlertDismissal.findOneAndUpdate',
'AdminAlertDismissal.find',
'AdminAlertDismissal.deleteMany'
'AdminAlertDismissal.deleteMany',
'WikiArticle.find',
'WikiArticle.findOneAndUpdate',
'fetchAllDocuments('
]
describe('Admin endpoint auth guards', () => {

View 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)
})
})