diff --git a/app/src/lib/content-api.js b/app/src/lib/content-api.js new file mode 100644 index 0000000..a953b69 --- /dev/null +++ b/app/src/lib/content-api.js @@ -0,0 +1,115 @@ +import { api } from './api.js'; +import { TeknavData } from '../../teknav-data.js'; + +function toQuery(params = {}) { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') query.set(key, String(value)); + }); + const s = query.toString(); + return s ? `?${s}` : ''; +} + +const articleCache = new Map(); + +export const contentApi = { + async listArticles(params = {}) { + const res = await api.get(`/api/articles${toQuery(params)}`); + return res; + }, + + async getArticle(slug) { + if (articleCache.has(slug)) { + return articleCache.get(slug); + } + const res = await api.get(`/api/articles/${encodeURIComponent(slug)}`); + articleCache.set(slug, res); + return res; + }, + + async getRelated(slug) { + return api.get(`/api/articles/${encodeURIComponent(slug)}/related`); + }, + + async listSeries() { + const res = await api.get('/api/series'); + return res?.items ?? []; + }, + + async getSeries(slug) { + const res = await api.get(`/api/series/${encodeURIComponent(slug)}`); + return res?.series; + }, + + async listNewsletterArchive() { + const res = await api.get('/api/newsletter/archive'); + return res?.items ?? []; + }, + + async getNewsletterIssue(slug) { + const res = await api.get(`/api/newsletter/archive/${encodeURIComponent(slug)}`); + return res?.campaign; + }, + + async search(params = {}) { + return api.get(`/api/search${toQuery(params)}`); + }, + + async listCategories() { + const res = await api.get('/api/categories'); + return res?.items ?? []; + }, + + async getCategory(slug) { + const res = await api.get(`/api/categories/${encodeURIComponent(slug)}`); + return res?.category; + }, + + async listAuthors() { + const res = await api.get('/api/authors'); + return res?.items ?? []; + }, + + async getAuthor(slug) { + const res = await api.get(`/api/authors/${encodeURIComponent(slug)}`); + return res?.author; + }, + + async listJobs() { + const res = await api.get('/api/jobs'); + return res?.items ?? []; + }, + + submitJob(payload) { + return api.post('/api/jobs', payload); + }, + + async listCourses() { + const res = await api.get('/api/courses'); + return res?.items ?? []; + }, + + subscribeNewsletter(email, captchaId, userSolution) { + return api.post('/api/newsletter', { email, captchaId, userSolution }); + }, + + trackEvent(payload) { + return api.post('/api/analytics/events', payload); + }, + + toggleReaction(articleId, type) { + return api.post(`/api/articles/${encodeURIComponent(articleId)}/reactions`, { type }); + }, + + toggleSaved(articleId) { + return api.post(`/api/articles/${encodeURIComponent(articleId)}/save`, {}); + }, + + fallbackArticles() { + return TeknavData.articles ?? []; + }, + + fallbackAuthors() { + return TeknavData.authors ?? []; + }, +}; diff --git a/app/src/lib/content-api.test.js b/app/src/lib/content-api.test.js new file mode 100644 index 0000000..4a78bb8 --- /dev/null +++ b/app/src/lib/content-api.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./api.js', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + }, + ApiError: class extends Error { + constructor(s, b) { super(); this.status = s; this.body = b; } + }, +})); + +import { contentApi } from './content-api.js'; +import { api } from './api.js'; + +describe('contentApi', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('listArticles calls GET /api/articles', async () => { + api.get.mockResolvedValue({ items: [{ slug: 'test' }], total: 1 }); + const res = await contentApi.listArticles({ limit: 10 }); + expect(api.get).toHaveBeenCalledWith('/api/articles?limit=10'); + expect(res.items).toEqual([{ slug: 'test' }]); + }); + + it('getArticle calls GET /api/articles/:slug', async () => { + api.get.mockResolvedValue({ article: { slug: 'test', title: 'Test' } }); + const res = await contentApi.getArticle('test-unique-slug-1'); + expect(api.get).toHaveBeenCalledWith('/api/articles/test-unique-slug-1'); + expect(res.article.slug).toBe('test'); + }); + + it('getArticle caches results', async () => { + api.get.mockClear(); + api.get.mockResolvedValue({ article: { slug: 'cache-test-slug-999' } }); + await contentApi.getArticle('cache-test-slug-999'); + const callCount = api.get.mock.calls.length; + await contentApi.getArticle('cache-test-slug-999'); + expect(api.get.mock.calls.length).toBe(callCount); + }); + + it('search calls GET /api/search with query', async () => { + api.get.mockResolvedValue({ items: [] }); + await contentApi.search({ q: 'هوش مصنوعی' }); + expect(api.get).toHaveBeenCalledWith('/api/search?q=%D9%87%D9%88%D8%B4+%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C'); + }); + + it('listCategories calls GET /api/categories', async () => { + api.get.mockResolvedValue({ items: [{ slug: 'ai', name: 'هوش مصنوعی' }] }); + const res = await contentApi.listCategories(); + expect(res[0].slug).toBe('ai'); + }); + + it('toggleReaction calls POST with article ID and type', async () => { + api.post.mockResolvedValue({ ok: true }); + await contentApi.toggleReaction('art123', 'insightful'); + expect(api.post).toHaveBeenCalledWith('/api/articles/art123/reactions', { type: 'insightful' }); + }); + + it('subscribeNewsletter sends email and captcha data', async () => { + api.post.mockResolvedValue({ ok: true }); + await contentApi.subscribeNewsletter('test@example.com', 'cap-id', 'ABC'); + expect(api.post).toHaveBeenCalledWith('/api/newsletter', { + email: 'test@example.com', + captchaId: 'cap-id', + userSolution: 'ABC', + }); + }); + + it('fallbackArticles returns array from TeknavData', () => { + const result = contentApi.fallbackArticles(); + expect(Array.isArray(result)).toBe(true); + }); + + it('fallbackAuthors returns array from TeknavData', () => { + const result = contentApi.fallbackAuthors(); + expect(Array.isArray(result)).toBe(true); + }); +});