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:
2026-10-12 04:31:32 +00:00
parent ed82166745
commit c47aa57909
19 changed files with 710 additions and 246 deletions

View File

@@ -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 }) => {