Per-user read state and subscriptions

Read, starred and position move to entry_state; subscriptions carry each
person's keywords, auto-download, explicit and per-scan limit. The feed
list and unread counts are per person, and the existing library is
adopted by the admin on first start.

The feed URL, folder and schedule stay shared and admin-only: one file
serves everyone, so they describe the file rather than a preference.
Scanning merges subscribers' wants -- anyone wanting an item is enough --
via merge_policy, which is pure and tested.

Also: the test fixture wiped its data directory from every Playwright
worker, deleting the database out from under the running daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-11 02:17:16 +00:00
parent 4810bb5cfb
commit d46ec73261
7 changed files with 707 additions and 93 deletions

View File

@@ -256,3 +256,47 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
expect(errors).toEqual([]);
});
test('a second person has their own feeds and their own read state', async ({ browser }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = {
...process.env,
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
};
try {
execFileSync('./target/debug/ipx', ['user', 'add', 'sam'], { input: 'sampassword', env });
} catch (e) {
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
}
// A fresh context, so none of the admin's cookies come along.
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click();
await expect(page.locator('#feedlist')).toBeVisible();
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
await expect(page.locator('#feedlist')).toContainText('No feeds.');
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
// files, but Sam's own read state.
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/show.xml');
await page.locator('#nsave').click();
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
// The admin read these earlier in this file; for Sam they are all still unread.
const rows = await page.locator('.ep').count();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep')).toHaveCount(rows);
await ctx.close();
});

View File

@@ -10,7 +10,15 @@ const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not
// 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
// daemon launches -- it would fall back to the real config and fight the live daemon.
// Playwright imports this config again in every worker process, so prepare() runs more
// than once per suite. Wiping on the second call deleted the data directory out from under
// the running daemon: it kept serving from the unlinked inode, while anything else opening
// that path -- the CLI, a query -- got a brand new empty database and disagreed with it.
function prepare() {
// Only the process that launches the run may wipe. A worker gets TEST_WORKER_INDEX.
if (process.env.TEST_WORKER_INDEX !== undefined || process.env.PW_WORKER_INDEX !== undefined) {
return;
}
fs.rmSync(root, { recursive: true, force: true });
for (const d of ['config', 'data', 'downloads']) {
fs.mkdirSync(path.join(root, d), { recursive: true });