feat(wiki): add admin wiki management API routes
This commit is contained in:
parent
3797ff7925
commit
e4f2efd6d0
5 changed files with 375 additions and 0 deletions
28
server/api/admin/wiki/[id].patch.js
Normal file
28
server/api/admin/wiki/[id].patch.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import * as z from 'zod'
|
||||||
|
import WikiArticle from '../../../models/wikiArticle.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
|
||||||
|
const wikiTagsSchema = z.object({
|
||||||
|
tags: z.array(z.string())
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
const body = await validateBody(event, wikiTagsSchema)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const article = await WikiArticle.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{ tags: body.tags },
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return article
|
||||||
|
})
|
||||||
55
server/api/admin/wiki/batch-tag.post.js
Normal file
55
server/api/admin/wiki/batch-tag.post.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import * as z from 'zod'
|
||||||
|
import WikiArticle from '../../../models/wikiArticle.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
|
||||||
|
const batchTagSchema = z.object({
|
||||||
|
articleIds: z.array(z.string()).optional(),
|
||||||
|
collection: z.string().optional(),
|
||||||
|
addTags: z.array(z.string()).optional(),
|
||||||
|
removeTags: z.array(z.string()).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
|
||||||
|
const body = await validateBody(event, batchTagSchema)
|
||||||
|
|
||||||
|
if (!body.articleIds && !body.collection) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Must provide either articleIds or collection'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.addTags?.length && !body.removeTags?.length) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Must provide at least one of addTags or removeTags'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const filter = body.articleIds
|
||||||
|
? { _id: { $in: body.articleIds } }
|
||||||
|
: { collection: body.collection }
|
||||||
|
|
||||||
|
let modified = 0
|
||||||
|
|
||||||
|
if (body.addTags?.length) {
|
||||||
|
const result = await WikiArticle.updateMany(filter, {
|
||||||
|
$addToSet: { tags: { $each: body.addTags } }
|
||||||
|
})
|
||||||
|
modified = result.modifiedCount || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.removeTags?.length) {
|
||||||
|
const result = await WikiArticle.updateMany(filter, {
|
||||||
|
$pull: { tags: { $in: body.removeTags } }
|
||||||
|
})
|
||||||
|
// Use the higher count if both operations ran
|
||||||
|
modified = Math.max(modified, result.modifiedCount || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modified }
|
||||||
|
})
|
||||||
24
server/api/admin/wiki/index.get.js
Normal file
24
server/api/admin/wiki/index.get.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import WikiArticle from '../../../models/wikiArticle.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const { collection, search } = getQuery(event)
|
||||||
|
|
||||||
|
const filter = {}
|
||||||
|
if (collection) {
|
||||||
|
filter.collection = collection
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
filter.title = { $regex: search, $options: 'i' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = await WikiArticle.find(filter)
|
||||||
|
.select('collection title tags url outlineId publishedAt')
|
||||||
|
.sort({ collection: 1, title: 1 })
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
return articles
|
||||||
|
})
|
||||||
20
server/models/wikiArticle.js
Normal file
20
server/models/wikiArticle.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const wikiArticleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
outlineId: { type: String, unique: true, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
collection: String,
|
||||||
|
url: { type: String, required: true },
|
||||||
|
summary: String,
|
||||||
|
tags: [{ type: String }],
|
||||||
|
publishedAt: Date,
|
||||||
|
permission: String,
|
||||||
|
lastSyncedAt: Date,
|
||||||
|
outlineUpdatedAt: Date
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
export default mongoose.models.WikiArticle ||
|
||||||
|
mongoose.model('WikiArticle', wikiArticleSchema)
|
||||||
248
tests/server/api/admin-wiki.test.js
Normal file
248
tests/server/api/admin-wiki.test.js
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/wikiArticle.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn(),
|
||||||
|
findByIdAndUpdate: vi.fn(),
|
||||||
|
updateMany: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
|
|
||||||
|
import WikiArticle from '../../../server/models/wikiArticle.js'
|
||||||
|
import indexHandler from '../../../server/api/admin/wiki/index.get.js'
|
||||||
|
import patchHandler from '../../../server/api/admin/wiki/[id].patch.js'
|
||||||
|
import batchTagHandler from '../../../server/api/admin/wiki/batch-tag.post.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
// --- GET /api/admin/wiki ---
|
||||||
|
|
||||||
|
describe('GET /api/admin/wiki', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns all articles with collection, title, tags', async () => {
|
||||||
|
const articles = [
|
||||||
|
{ _id: 'a1', collection: 'General', title: 'Alpha', tags: ['co-ops'], url: '/doc/alpha', outlineId: 'o1', publishedAt: new Date() },
|
||||||
|
{ _id: 'a2', collection: 'General', title: 'Beta', tags: [], url: '/doc/beta', outlineId: 'o2', publishedAt: new Date() }
|
||||||
|
]
|
||||||
|
WikiArticle.find.mockReturnValue({
|
||||||
|
select: () => ({
|
||||||
|
sort: () => ({
|
||||||
|
lean: () => Promise.resolve(articles)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/wiki' })
|
||||||
|
const result = await indexHandler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual(articles)
|
||||||
|
expect(WikiArticle.find).toHaveBeenCalledWith({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by collection', async () => {
|
||||||
|
WikiArticle.find.mockReturnValue({
|
||||||
|
select: () => ({
|
||||||
|
sort: () => ({
|
||||||
|
lean: () => Promise.resolve([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/wiki?collection=Guides'
|
||||||
|
})
|
||||||
|
await indexHandler(event)
|
||||||
|
|
||||||
|
expect(WikiArticle.find).toHaveBeenCalledWith({ collection: 'Guides' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches by title (case-insensitive)', async () => {
|
||||||
|
WikiArticle.find.mockReturnValue({
|
||||||
|
select: () => ({
|
||||||
|
sort: () => ({
|
||||||
|
lean: () => Promise.resolve([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/wiki?search=cooperative'
|
||||||
|
})
|
||||||
|
await indexHandler(event)
|
||||||
|
|
||||||
|
expect(WikiArticle.find).toHaveBeenCalledWith({
|
||||||
|
title: { $regex: 'cooperative', $options: 'i' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires admin auth (403)', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Admin access required' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/wiki' })
|
||||||
|
await expect(indexHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- PATCH /api/admin/wiki/:id ---
|
||||||
|
|
||||||
|
describe('PATCH /api/admin/wiki/:id', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
validateBody.mockImplementation(async (event) => readBody(event))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets tags on an article', async () => {
|
||||||
|
const updated = {
|
||||||
|
_id: 'a1',
|
||||||
|
title: 'Alpha',
|
||||||
|
tags: ['co-ops', 'governance'],
|
||||||
|
collection: 'General'
|
||||||
|
}
|
||||||
|
WikiArticle.findByIdAndUpdate.mockResolvedValue(updated)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/admin/wiki/a1',
|
||||||
|
body: { tags: ['co-ops', 'governance'] }
|
||||||
|
})
|
||||||
|
// Simulate getRouterParam returning the id
|
||||||
|
event.context.params = { id: 'a1' }
|
||||||
|
|
||||||
|
const result = await patchHandler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual(updated)
|
||||||
|
expect(WikiArticle.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
|
'a1',
|
||||||
|
{ tags: ['co-ops', 'governance'] },
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 for invalid article ID', async () => {
|
||||||
|
WikiArticle.findByIdAndUpdate.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/admin/wiki/nonexistent',
|
||||||
|
body: { tags: ['test'] }
|
||||||
|
})
|
||||||
|
event.context.params = { id: 'nonexistent' }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires admin auth (403)', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Admin access required' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/admin/wiki/a1',
|
||||||
|
body: { tags: ['test'] }
|
||||||
|
})
|
||||||
|
event.context.params = { id: 'a1' }
|
||||||
|
|
||||||
|
await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- POST /api/admin/wiki/batch-tag ---
|
||||||
|
|
||||||
|
describe('POST /api/admin/wiki/batch-tag', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' })
|
||||||
|
validateBody.mockImplementation(async (event) => readBody(event))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds tags to multiple articles (add/merge semantics)', async () => {
|
||||||
|
WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 3 })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/batch-tag',
|
||||||
|
body: {
|
||||||
|
articleIds: ['a1', 'a2', 'a3'],
|
||||||
|
addTags: ['governance', 'finance']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await batchTagHandler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ modified: 3 })
|
||||||
|
expect(WikiArticle.updateMany).toHaveBeenCalledWith(
|
||||||
|
{ _id: { $in: ['a1', 'a2', 'a3'] } },
|
||||||
|
{ $addToSet: { tags: { $each: ['governance', 'finance'] } } }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes tags from multiple articles', async () => {
|
||||||
|
WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 2 })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/batch-tag',
|
||||||
|
body: {
|
||||||
|
collection: 'Guides',
|
||||||
|
removeTags: ['outdated']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await batchTagHandler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ modified: 2 })
|
||||||
|
expect(WikiArticle.updateMany).toHaveBeenCalledWith(
|
||||||
|
{ collection: 'Guides' },
|
||||||
|
{ $pull: { tags: { $in: ['outdated'] } } }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not duplicate existing tags on add', async () => {
|
||||||
|
// $addToSet guarantees no duplicates — verify the operator is used
|
||||||
|
WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 1 })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/batch-tag',
|
||||||
|
body: {
|
||||||
|
articleIds: ['a1'],
|
||||||
|
addTags: ['co-ops']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await batchTagHandler(event)
|
||||||
|
|
||||||
|
// Confirm $addToSet is used, not $push
|
||||||
|
const call = WikiArticle.updateMany.mock.calls[0]
|
||||||
|
expect(call[1]).toHaveProperty('$addToSet')
|
||||||
|
expect(call[1]).not.toHaveProperty('$push')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires admin auth (403)', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Admin access required' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/wiki/batch-tag',
|
||||||
|
body: {
|
||||||
|
articleIds: ['a1'],
|
||||||
|
addTags: ['test']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(batchTagHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue