From d3a5c1a3a70dc564b9336de37d29a503ae1990b1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:36:19 +0100 Subject: [PATCH] feat(wiki): add tag-based wiki recommendations API --- server/api/wiki/recommended.get.js | 32 +++++ server/models/wikiArticle.js | 20 +++ tests/server/api/wiki-recommended.test.js | 165 ++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 server/api/wiki/recommended.get.js create mode 100644 server/models/wikiArticle.js create mode 100644 tests/server/api/wiki-recommended.test.js diff --git a/server/api/wiki/recommended.get.js b/server/api/wiki/recommended.get.js new file mode 100644 index 0000000..104960a --- /dev/null +++ b/server/api/wiki/recommended.get.js @@ -0,0 +1,32 @@ +import WikiArticle from '../../models/wikiArticle.js' +import { connectDB } from '../../utils/mongoose.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + + // Combine craft tags and cooperative ecology tags + const craftTags = member.craftTags || [] + const ecologyTags = (member.communityEcology?.topics || []).map(t => t.tagSlug) + const memberTags = [...new Set([...craftTags, ...ecologyTags].filter(Boolean))] + + if (!memberTags.length) { + return [] + } + + await connectDB() + + const query = getQuery(event) + const limit = Math.min(Math.max(parseInt(query.limit) || 10, 1), 25) + + const articles = await WikiArticle.find({ + tags: { $in: memberTags }, + publishedAt: { $ne: null } + }) + .sort({ publishedAt: -1 }) + .limit(limit) + .select('title url summary tags collection publishedAt') + .lean() + + return articles +}) diff --git a/server/models/wikiArticle.js b/server/models/wikiArticle.js new file mode 100644 index 0000000..275ba7d --- /dev/null +++ b/server/models/wikiArticle.js @@ -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) diff --git a/tests/server/api/wiki-recommended.test.js b/tests/server/api/wiki-recommended.test.js new file mode 100644 index 0000000..c6ae5f0 --- /dev/null +++ b/tests/server/api/wiki-recommended.test.js @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFind, mockSort, mockLimit, mockSelect, mockLean } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockSort: vi.fn(), + mockLimit: vi.fn(), + mockSelect: vi.fn(), + mockLean: vi.fn() +})) + +vi.mock('../../../server/models/wikiArticle.js', () => ({ + default: { find: mockFind } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +vi.mock('../../../server/utils/auth.js', () => ({ + requireAuth: vi.fn() +})) + +import { requireAuth } from '../../../server/utils/auth.js' +import handler from '../../../server/api/wiki/recommended.get.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Wire up the chained query builder +function setupChain(result = []) { + mockLean.mockResolvedValue(result) + mockSelect.mockReturnValue({ lean: mockLean }) + mockLimit.mockReturnValue({ select: mockSelect }) + mockSort.mockReturnValue({ limit: mockLimit }) + mockFind.mockReturnValue({ sort: mockSort }) +} + +function makeMember(overrides = {}) { + return { + _id: 'member-1', + craftTags: [], + communityEcology: { topics: [] }, + ...overrides + } +} + +function makeArticle(overrides = {}) { + return { + _id: 'article-1', + title: 'Test Article', + url: 'https://wiki.example.com/test-article', + summary: 'A test article', + tags: ['game-design'], + collection: 'Guides', + publishedAt: new Date('2026-04-01'), + ...overrides + } +} + +describe('GET /api/wiki/recommended', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns articles matching member tags', async () => { + const member = makeMember({ craftTags: ['game-design', 'narrative'] }) + requireAuth.mockResolvedValue(member) + + const articles = [makeArticle({ tags: ['game-design'] })] + setupChain(articles) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + const result = await handler(event) + + expect(result).toEqual(articles) + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { $in: expect.arrayContaining(['game-design', 'narrative']) } + }) + ) + }) + + it('returns empty array when no tag overlap', async () => { + const member = makeMember({ craftTags: ['audio'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + const result = await handler(event) + + expect(result).toEqual([]) + }) + + it('excludes unpublished articles (publishedAt: null)', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + await handler(event) + + const filter = mockFind.mock.calls[0][0] + expect(filter.publishedAt).toEqual({ $ne: null }) + }) + + it('uses default limit of 10', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(10) + }) + + it('accepts limit query param', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended?limit=5' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(5) + }) + + it('caps limit at 25', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended?limit=100' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(25) + }) + + it('returns empty array when member has no tags', async () => { + const member = makeMember() + requireAuth.mockResolvedValue(member) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + const result = await handler(event) + + expect(result).toEqual([]) + // Should not query the database at all + expect(mockFind).not.toHaveBeenCalled() + }) + + it('requires auth (401)', async () => { + requireAuth.mockRejectedValue( + createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + ) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + }) +})