Files
Jan Wagner 97944875ca feat: Phase 1 - Admin CMS with Links Management
Backend (updates-api):
- Add links table schema with categories, visibility, sorting
- Implement full CRUD API for links (/v1/links, /v1/admin/links)
- Create admin UI for links management (/admin/links)
- Add navigation between Updates and Links admin pages
- Add comprehensive tests for links API (7/7 passing)

Frontend (Next.js):
- Create API client for fetching links from updates-api
- Add LinksSection component with category icons
- Integrate links into homepage (between Notes and CTA)
- Add i18n entries for links section (DE/EN)
- ISR caching: 60s for links, 5min for updates

Features:
- Links organized by category (external/internal/resource/tool)
- Visibility toggle (show/hide on homepage)
- Sort order support
- Optional icons and descriptions
- Responsive grid layout (1/2/3 columns)

Testing:
- All existing tests pass
- New links API tests cover CRUD + validation
- Production build successful

Phase 1 Complete: Links management ready for deployment
Next: Phase 2 (Markdown editor + media upload)
2026-08-22 16:56:00 +02:00

169 lines
5.0 KiB
JavaScript

import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { createApp } from '../src/server.js';
const TOKEN = 'test-token-' + randomUUID();
test('links CRUD workflow', async () => {
const dbPath = join(tmpdir(), `links-test-${randomUUID()}.sqlite`);
const app = createApp({ dbPath, adminToken: TOKEN });
const port = 3000 + Math.floor(Math.random() * 1000);
const server = app.server.listen(port);
const base = `http://127.0.0.1:${port}`;
try {
// Create link
let res = await fetch(`${base}/v1/admin/links`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Test Link',
url: 'https://example.com',
category: 'external',
description: 'A test link',
visible: true,
sort_order: 5
})
});
assert.equal(res.status, 201, 'create should return 201');
const created = await res.json();
assert.ok(created.data.id, 'created link should have id');
assert.equal(created.data.title, 'Test Link');
const linkId = created.data.id;
// List all links (admin)
res = await fetch(`${base}/v1/admin/links`, {
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
assert.equal(res.status, 200);
let list = await res.json();
assert.equal(list.data.length, 1, 'should have 1 link');
// Get single link
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
assert.equal(res.status, 200);
const single = await res.json();
assert.equal(single.data.title, 'Test Link');
// Update link
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Updated Link',
visible: false
})
});
assert.equal(res.status, 200);
const updated = await res.json();
assert.equal(updated.data.title, 'Updated Link');
assert.equal(updated.data.visible, 0);
// Public endpoint should not show invisible links
res = await fetch(`${base}/v1/links`);
assert.equal(res.status, 200);
const publicLinks = await res.json();
assert.equal(publicLinks.data.length, 0, 'invisible links should not appear in public endpoint');
// Make visible again
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ visible: true })
});
assert.equal(res.status, 200);
// Public endpoint should now show link
res = await fetch(`${base}/v1/links`);
const publicVisible = await res.json();
assert.equal(publicVisible.data.length, 1, 'visible links should appear in public endpoint');
// Delete link
res = await fetch(`${base}/v1/admin/links/${linkId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
assert.equal(res.status, 200);
// Verify deletion
res = await fetch(`${base}/v1/admin/links`, {
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
list = await res.json();
assert.equal(list.data.length, 0, 'link should be deleted');
} finally {
server.close();
app.close();
}
});
test('links validation', async () => {
const dbPath = join(tmpdir(), `links-validation-${randomUUID()}.sqlite`);
const app = createApp({ dbPath, adminToken: TOKEN });
const port = 3001 + Math.floor(Math.random() * 1000);
const server = app.server.listen(port);
const base = `http://127.0.0.1:${port}`;
try {
// Missing title
let res = await fetch(`${base}/v1/admin/links`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://example.com'
})
});
assert.equal(res.status, 400, 'should reject missing title');
// Missing URL
res = await fetch(`${base}/v1/admin/links`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Test'
})
});
assert.equal(res.status, 400, 'should reject missing url');
// Invalid category
res = await fetch(`${base}/v1/admin/links`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Test',
url: 'https://example.com',
category: 'invalid'
})
});
assert.equal(res.status, 400, 'should reject invalid category');
} finally {
server.close();
app.close();
}
});