/** * E2E test: Sources CRUD via API. * * Validates: * 1. Creating a theme (sources now belong to themes) * 2. Adding a source to the theme * 3. Listing sources for the theme * 4. Deleting a source * 5. Cleanup: deleting the theme */ import { test, expect } from '@playwright/test'; import { loginAsUser } from '../helpers/auth'; test.describe('Sources management', () => { test('should add and delete sources via API', async ({ page }) => { // Step 1: Login as regular user via cookie injection await loginAsUser(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('domcontentloaded'); // Step 2: Create a theme first (sources now belong to themes) const themeResp = await page.evaluate(async () => { const resp = await fetch('/api/v1/themes', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, credentials: 'same-origin', body: JSON.stringify({ name: 'Source Test Theme', theme: 'Test', categories: ['Cat'], }), }); return { status: resp.status, data: await resp.json() }; }); expect(themeResp.status).toBe(201); const themeId = themeResp.data.id; // Step 3: Add a source to the theme const addResp = await page.evaluate( async (tid: string) => { const resp = await fetch('/api/v1/sources', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, credentials: 'same-origin', body: JSON.stringify({ title: 'Test Blog', url: 'https://test.example.com/blog', theme_id: tid, }), }); return { status: resp.status, data: await resp.json() }; }, themeId, ); expect(addResp.status).toBe(201); const sourceId = addResp.data.id; // Step 4: Add a second source via bulk-style (individual POST) const addResp2 = await page.evaluate( async (tid: string) => { const resp = await fetch('/api/v1/sources', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, credentials: 'same-origin', body: JSON.stringify({ title: 'News Site', url: 'https://news.example.com', theme_id: tid, }), }); return { status: resp.status, data: await resp.json() }; }, themeId, ); expect(addResp2.status).toBe(201); // Step 5: List sources for the theme and verify we have 2 const listResp = await page.evaluate(async (tid: string) => { const resp = await fetch(`/api/v1/sources?theme_id=${tid}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', }); return { status: resp.status, data: await resp.json() }; }, themeId); expect(listResp.status).toBe(200); expect(listResp.data.length).toBe(2); // Step 6: Delete the first source (Test Blog) const delResp = await page.evaluate(async (sid: string) => { const resp = await fetch(`/api/v1/sources/${sid}`, { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', }); return { status: resp.status }; }, sourceId); expect(delResp.status).toBe(204); // Step 7: Verify only 1 source remains const listResp2 = await page.evaluate(async (tid: string) => { const resp = await fetch(`/api/v1/sources?theme_id=${tid}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', }); return { status: resp.status, data: await resp.json() }; }, themeId); expect(listResp2.status).toBe(200); expect(listResp2.data.length).toBe(1); expect(listResp2.data[0].title).toBe('News Site'); // Step 8: Mark a source as preferred via API const sourceId2 = addResp2.data.id; const prefResp = await page.evaluate(async ({ ids, tid }: { ids: string[]; tid: string }) => { const resp = await fetch('/api/v1/sources/preferred', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, credentials: 'same-origin', body: JSON.stringify({ source_ids: ids, theme_id: tid }), }); return { status: resp.status }; }, { ids: [sourceId2], tid: themeId }); expect(prefResp.status).toBe(204); // Step 9: Verify source is now preferred const listResp3 = await page.evaluate(async (tid: string) => { const resp = await fetch(`/api/v1/sources?theme_id=${tid}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', }); return { status: resp.status, data: await resp.json() }; }, themeId); expect(listResp3.status).toBe(200); const preferred = listResp3.data.filter((s: any) => s.is_preferred); expect(preferred.length).toBe(1); expect(preferred[0].id).toBe(sourceId2); // Step 10: Cleanup - delete theme (cascades sources) await page.evaluate(async (tid: string) => { await fetch(`/api/v1/themes/${tid}`, { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', }); }, themeId); }); });