diff --git a/app/backend/tests/public-contract.test.mjs b/app/backend/tests/public-contract.test.mjs new file mode 100644 index 0000000..cf261e6 --- /dev/null +++ b/app/backend/tests/public-contract.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const BASE = process.env.TEST_BASE_URL || 'http://127.0.0.1:3010'; +const TEST_ARTICLE_SLUG = process.env.TEST_ARTICLE_SLUG || 'agentic-ai-production-2026'; +const REQUIRE_SERVER = process.env.TEST_REQUIRE_SERVER === '1'; + +async function serverAvailable() { + try { + const res = await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(1500) }); + return res.status < 500; + } catch { + return false; + } +} + +const HAS_SERVER = await serverAvailable(); +if (!HAS_SERVER && REQUIRE_SERVER) { + throw new Error(`TEST_REQUIRE_SERVER=1 but no backend is reachable at ${BASE}`); +} +const SERVER_SKIP = HAS_SERVER ? false : `no backend reachable at ${BASE}`; + +async function request(path, options = {}) { + const headers = { + 'content-type': 'application/json', + ...(options.headers || {}), + }; + const res = await fetch(`${BASE}${path}`, { ...options, headers }); + const body = await res.json().catch(() => null); + return { res, body }; +} + +test('public content endpoints expose stable list payloads', { skip: SERVER_SKIP }, async () => { + const endpoints = [ + ['/api/v1/articles?limit=3', 'items'], + ['/api/v1/categories', 'items'], + ['/api/v1/tags', 'items'], + ['/api/v1/authors', 'items'], + ['/api/v1/series', 'items'], + ['/api/v1/newsletter/archive', 'items'], + ['/api/v1/jobs', 'items'], + ['/api/v1/courses', 'items'], + ]; + + for (const [path, key] of endpoints) { + const { res, body } = await request(path); + assert.equal(res.status, 200, `${path} should be reachable`); + assert.ok(Array.isArray(body?.[key]), `${path} should return ${key} array`); + } +}); + +test('article detail, comments, related, and qa endpoints keep their contracts', { skip: SERVER_SKIP }, async () => { + const article = await request(`/api/v1/articles/${TEST_ARTICLE_SLUG}`); + assert.equal(article.res.status, 200); + assert.ok(article.body?.article?.id); + assert.equal(article.body.article.slug, TEST_ARTICLE_SLUG); + + const comments = await request(`/api/v1/articles/${TEST_ARTICLE_SLUG}/comments`); + assert.equal(comments.res.status, 200); + assert.ok(Array.isArray(comments.body?.items)); + + const related = await request(`/api/v1/articles/${TEST_ARTICLE_SLUG}/related`); + assert.equal(related.res.status, 200); + assert.ok(Array.isArray(related.body?.items)); + + const qa = await request(`/api/v1/articles/${TEST_ARTICLE_SLUG}/qa`); + assert.equal(qa.res.status, 200); + assert.ok(Array.isArray(qa.body?.items)); +}); + +test('missing public resources return 404 instead of leaking internals', { skip: SERVER_SKIP }, async () => { + const article = await request('/api/v1/articles/definitely-missing-article'); + assert.equal(article.res.status, 404); + assert.equal(article.body?.error, 'not_found'); + + const profile = await request('/api/v1/profile/@definitely-missing-user'); + assert.equal(profile.res.status, 404); + assert.equal(profile.body?.error, 'not_found'); +}); + +test('authenticated surfaces reject anonymous requests consistently', { skip: SERVER_SKIP }, async () => { + const protectedGets = [ + '/api/v1/auth/me', + '/api/v1/auth/lists', + '/api/v1/auth/history', + '/api/v1/auth/notifications', + '/api/v1/auth/notifications/preferences', + '/api/v1/auth/badges', + '/api/v1/membership/status', + '/api/v1/admin/dashboard', + '/api/v1/admin/errors', + '/api/v1/admin/articles', + ]; + + for (const path of protectedGets) { + const { res, body } = await request(path); + assert.equal(res.status, 401, `${path} should reject anonymous access`); + assert.equal(body?.error, 'unauthenticated'); + } +}); + +test('public mutating endpoints validate bad input without creating records', { skip: SERVER_SKIP }, async () => { + const newsletter = await request('/api/v1/newsletter', { + method: 'POST', + body: JSON.stringify({ email: 'not-an-email' }), + }); + assert.equal(newsletter.res.status, 400); + assert.equal(newsletter.body?.error, 'invalid_input'); + + const errorReport = await request('/api/v1/errors', { + method: 'POST', + body: JSON.stringify({ message: '' }), + }); + assert.equal(errorReport.res.status, 400); + assert.equal(errorReport.body?.error, 'invalid_input'); + + const job = await request('/api/v1/jobs', { + method: 'POST', + body: JSON.stringify({ company: 'x', title: 'y', description: 'too short' }), + }); + assert.equal(job.res.status, 400); + assert.equal(job.body?.error, 'invalid_input'); +});