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)
169 lines
5.0 KiB
JavaScript
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();
|
|
}
|
|
});
|