Import tests: app/backend/tests/public-contract.test.mjs
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user