The Directory comes from the directory service (#205)
ipx asks ipx-directory on its people's behalf, with the token an admin set in the new Admin, Directory tab, so the page never talks to it and never sees the token or a feed's address. /api/directory, /api/popular and a feed's Directory page answer from it; subscribing takes the address from the directory and goes through Add feed's steps. With no directory set up, the Directory says so. The local directory code is gone; its privacy checks are the snapshot builder's now, and their tests moved with them. With sharing on, ipx sends its snapshot after every scan, whenever the config changes (set_cfg) and every half hour: each public feed and how many here subscribe, never an OPML or Patreon creator itself or anything private. A feed can be marked private in its settings, for a paid feed whose address does not give it away; anyone subscribed may mark it, only an admin unmark it. The admin's category for a feed that names none goes with it, which the directory uses only while the feed names none, so the category an admin gives a blog still reaches the Directory. The directory lists only feeds it has read as a feed: the first snapshot after a start went before the first scan, when an OPML was not yet known to be one, and the directory listed the OPML. It now fetches what a snapshot brings in at once instead of within the minute. The browser suite runs its own ipx-directory on SQLite; the Directory's tests match feeds by title, since the directory names them by address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -821,7 +821,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
|
||||
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
||||
});
|
||||
|
||||
test('the Directory lists what everyone here reads, the most subscribed first, but never a private feed', async ({ browser, page }) => {
|
||||
test('the Directory lists what servers share, the most subscribed first, but never a private feed', async ({ browser, page }) => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
const env = {
|
||||
@@ -863,11 +863,15 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
const top = await (await piper.request.get('/api/popular')).json();
|
||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||
expect(names).toEqual([...names].sort());
|
||||
// The directory's ids, not this server's: it names a feed by its address (#205).
|
||||
const ids = dir.map(p => p.id);
|
||||
expect(ids).not.toContain('test-subscriptions');
|
||||
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
|
||||
const titles = dir.map(p => p.title);
|
||||
const byTitle = t => dir.find(p => p.title === t);
|
||||
expect(titles).not.toContain('Test Subscriptions');
|
||||
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
|
||||
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||
expect(ids).not.toContain('paid-show');
|
||||
expect(titles).not.toContain('Paid Show');
|
||||
expect(ids).not.toContain('test-show');
|
||||
await expect(chart).toHaveCount(top.length);
|
||||
await expect(chart.first().locator('.no')).toHaveText('1');
|
||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||
@@ -875,8 +879,8 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
// Then the categories, as tiles, each a page of its own.
|
||||
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
|
||||
const tiles = piper.locator('#dtiles .tile');
|
||||
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
|
||||
expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||
expect(byTitle('Picture Blog')).toMatchObject({ podcast: false });
|
||||
await cat('Technology').click();
|
||||
await expect(piper.locator('#directory h2')).toHaveText('Technology');
|
||||
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
|
||||
@@ -927,7 +931,7 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
|
||||
await expect(tiles).toHaveCount(dir.length);
|
||||
// Only a listed feed has one, and it carries no file or its address.
|
||||
const preview = await (await piper.request.get('/api/directory/test-show')).text();
|
||||
const preview = await (await piper.request.get(`/api/directory/${byTitle('Test Show').id}`)).text();
|
||||
expect(preview).toContain('First Episode');
|
||||
expect(JSON.parse(preview).description).toBe('A fixture feed.');
|
||||
expect(preview).not.toContain('.mp3');
|
||||
@@ -953,14 +957,15 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
|
||||
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||
|
||||
const row = async () =>
|
||||
(await (await piper.request.get('/api/directory')).json()).find(p => p.id === 'test-show');
|
||||
(await (await piper.request.get('/api/directory')).json()).find(p => p.title === 'Test Show');
|
||||
const before = await row();
|
||||
expect(before.subscribed).toBe(false);
|
||||
expect(before).toMatchObject({ subscribed: false, feed: null });
|
||||
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
||||
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
||||
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
||||
// Everyone counts, you included: it stays listed, marked as yours by the id it has here, with
|
||||
// one more subscriber once this server has shared again.
|
||||
await expect.poll(row, { timeout: 20_000 }).toMatchObject({ subscribed: true, feed: 'test-show', subscribers: before.subscribers + 1 });
|
||||
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200);
|
||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
||||
@@ -1000,19 +1005,20 @@ test('ipx add and rm while the daemon runs reach it, and its next change keeps t
|
||||
const setup = require('./global-setup');
|
||||
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
|
||||
const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' });
|
||||
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.id);
|
||||
// By title: the directory names a feed by its address, not by its id here (#205).
|
||||
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
||||
// A change of the daemon's own: it writes the whole catalogue from the copy it holds, which
|
||||
// wrote a removed feed back and dropped an added one when that copy was old.
|
||||
const daemonWrites = category => page.evaluate(c =>
|
||||
api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category);
|
||||
|
||||
const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1];
|
||||
await expect.poll(listed).toContain(id);
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||
await daemonWrites('Comedy');
|
||||
expect(await listed()).toContain(id);
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||
|
||||
ipx('rm', id);
|
||||
await expect.poll(listed).not.toContain(id);
|
||||
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Command Line Show');
|
||||
await daemonWrites(null);
|
||||
expect(await listed()).not.toContain(id);
|
||||
});
|
||||
@@ -1299,11 +1305,14 @@ test('an API token acts as its owner until it is revoked', async ({ page, reques
|
||||
test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||
const patch = (id, category) => page.evaluate(([id, category]) =>
|
||||
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
||||
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
||||
// By title: the directory names a feed by its address, not by its id here (#205). It hears of
|
||||
// a category given here with this server's next snapshot, sent as soon as one is saved.
|
||||
const listed = async title => (await page.evaluate(() => api('/api/directory'))).find(p => p.title === title);
|
||||
|
||||
await patch('picture-blog', 'Visual Arts');
|
||||
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
||||
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||
await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
|
||||
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||
// A category's subcategories are on its own page, as Apple's directory has them.
|
||||
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
|
||||
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||
@@ -1314,10 +1323,52 @@ test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
|
||||
// A feed's own iTunes category wins over one given here.
|
||||
await patch('test-show', 'Comedy');
|
||||
expect((await listed('test-show')).category).toBe('Technology');
|
||||
expect((await listed('Test Show')).category).toBe('Technology');
|
||||
|
||||
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
||||
expect((await listed('picture-blog')).category).toBeNull();
|
||||
await expect.poll(async () => (await listed('Picture Blog')).category, { timeout: 20_000 }).toBeNull();
|
||||
});
|
||||
|
||||
test('the admin page sets up the directory, and a feed marked private leaves it (#205)', async ({ page }) => {
|
||||
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||
|
||||
await page.goto('/admin#directory');
|
||||
await expect(page.locator('#durl')).toHaveValue('http://127.0.0.1:8795');
|
||||
// The token is set and never shown: the field is empty, and says so.
|
||||
await expect(page.locator('#dtoken')).toHaveValue('');
|
||||
await expect(page.locator('#dtoken')).toHaveAttribute('placeholder', /^Set\./);
|
||||
const settings = await page.evaluate(() => api('/api/settings'));
|
||||
expect(settings.directory_token_set).toBe(true);
|
||||
expect(JSON.stringify(settings)).not.toContain('ipxd_');
|
||||
await expect(page.locator('#dstatus')).toContainText('Connected as test, which may browse, share and administer the directory.');
|
||||
await expect(page.locator('#dstatus')).toContainText(/Shared \d+ feeds? at/);
|
||||
|
||||
// A wrong token says so, in words.
|
||||
await page.locator('#dtoken').fill('not-the-token');
|
||||
await page.locator('#dsave').click();
|
||||
await expect(page.locator('#dstatus')).toContainText('does not accept this server');
|
||||
await page.locator('#dtoken').fill(require('./global-setup').DIRECTORY_TOKEN);
|
||||
await page.locator('#dsave').click();
|
||||
await expect(page.locator('#dstatus')).toContainText('Connected as test');
|
||||
|
||||
// Marked private in its settings, a feed is out of the directory with the next snapshot.
|
||||
await page.goto('/');
|
||||
await page.locator('.feed', { hasText: 'Multi Show' }).click();
|
||||
await page.locator('#content .acts [data-a="settings"]').click();
|
||||
const priv = page.locator('#sprivate');
|
||||
await expect(priv).not.toBeChecked();
|
||||
await priv.check({ force: true });
|
||||
await page.locator('#ssave').click();
|
||||
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Multi Show');
|
||||
// Its address shows nothing, so only the mark keeps it out; and an admin can take it off.
|
||||
const row = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'multi-show'));
|
||||
expect(row).toMatchObject({ private: true, private_by_address: false });
|
||||
await page.evaluate(() => api('/api/feeds/multi-show', { method: 'PATCH', body: JSON.stringify({ private: false }) }));
|
||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||
// A feed whose address carries a key is private whatever it is marked.
|
||||
const paid = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'paid-show'));
|
||||
if (paid) expect(paid.private_by_address).toBe(true);
|
||||
});
|
||||
|
||||
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
||||
|
||||
@@ -6,6 +6,9 @@ const os = require('os');
|
||||
|
||||
const root = path.join(os.tmpdir(), 'ipx-ui-test');
|
||||
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
|
||||
// The suite's own directory service (#205), and this daemon's token for it, which may do anything.
|
||||
const DIRECTORY = 'http://127.0.0.1:8795';
|
||||
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
|
||||
|
||||
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
|
||||
// webServer *before* globalSetup, so a config written there does not exist yet when the
|
||||
@@ -29,6 +32,9 @@ download_dir = "${path.join(root, 'downloads')}"
|
||||
socket = "${path.join(root, 'ipx.sock')}"
|
||||
schedule = "every 60m"
|
||||
max_new_per_check = 1
|
||||
directory_url = "${DIRECTORY}"
|
||||
directory_token = "${DIRECTORY_TOKEN}"
|
||||
directory_share = true
|
||||
|
||||
[torrent]
|
||||
enabled = false
|
||||
@@ -64,7 +70,16 @@ auto_download = false
|
||||
[feeds.paid-show]
|
||||
url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
|
||||
auto_download = false
|
||||
`);
|
||||
fs.writeFileSync(path.join(root, 'directory.toml'), `
|
||||
bind = "127.0.0.1:8795"
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
|
||||
[[tokens]]
|
||||
name = "test"
|
||||
access = "rwa"
|
||||
token_sha256 = "${require('crypto').createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
|
||||
`);
|
||||
}
|
||||
|
||||
module.exports = { prepare, root, TOKEN };
|
||||
module.exports = { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN };
|
||||
|
||||
Reference in New Issue
Block a user