import { describe, it, expect, vi, beforeEach } from 'vitest' import WikiArticle from '../../../server/models/wikiArticle.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' // 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/outline.js', () => ({ fetchAllDocuments: vi.fn(), fetchCollections: vi.fn(() => new Map()), extractSummary: vi.fn((text) => text || '') })) 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: 'https://wiki.ghostguild.org/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('
Hello world
')).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) }) })