dorfteich/apps/web/e2e/favorites.spec.ts
Claude Fable 5 6c98a71d34
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m32s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m51s
CI / Import/export fidelity gate (push) Has been skipped
Favorites: personal page stars, golden icons, sidebar filter (#132)
Semantics changed from the issue during planning (documented there,
comment 1192): favorites are PERSONAL per user, not pond-wide — the
sys-fav label approach is dropped entirely. Storage is a page_favorites
table (userId+pageId, FK cascade); PUT/DELETE /pages/:id/favorite
toggles idempotently and needs read access only (#60 404 semantics —
a star is a note-to-self, not a page modification), GET
/ponds/:id/favorites lists the account's stars sliced to still-readable
pages. Trashed pages keep their rows, so restore keeps the star; purge
cascades it away.

Web: one shared ['favorites', pondId] query feeds the TopBar star
(between labels and history, golden when set), the golden tree icons in
the sidebar, and a latching "Favorites" filter button next to the view
switch that narrows either view (combinable with the label filter).
No public-API/MCP exposure — with the label approach gone, that parity
is no longer free; favorites stay UI-only for now.

New favorites e2e pack (star toggle, golden icon, filter, per-user
isolation) wired into CI; DB suite covers the round-trip, read gating,
and the trash/restore/purge lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-16 12:03:55 +02:00

117 lines
4.7 KiB
TypeScript

import { expect, test, type BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Favorites pack (issue #132): the TopBar star toggle, the golden tree
* icon, the latching favorites filter in the sidebar, and that favorites
* are personal — stored per user, not per pond. Runs in its own shared
* pond so fixture ponds stay untouched. Language-independent selectors
* (CSS classes) throughout.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function json<T>(context: BrowserContext, url: string, data: unknown): Promise<T> {
const response = await context.request.post(url, { data });
if (!response.ok()) throw new Error(`post ${url}${response.status()}`);
return response.json() as Promise<T>;
}
test('star toggle, golden tree icon, and the favorites filter', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await json<{ id: string; slug: string }>(context, '/api/v1/ponds', {
name: `Fav Pack ${ts}`,
});
const starred = await json<{ id: string; slug: string }>(
context,
`/api/v1/ponds/${pond.id}/pages`,
{ title: `Fav Starred ${ts}` },
);
await json(context, `/api/v1/ponds/${pond.id}/pages`, { title: `Fav Plain ${ts}` });
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${starred.slug}`);
// Star the page from the TopBar (reading mode) — the button flips to
// "remove" state (aria-pressed) and turns golden.
const star = page.locator('.editor-page__favorite-toggle');
await expect(star).toHaveAttribute('aria-pressed', 'false');
await star.click();
await expect(star).toHaveAttribute('aria-pressed', 'true');
await expect(star).toHaveClass(/icon-button--favorite/);
// The sidebar tree marks the favorite with a golden icon.
const starredItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("Fav Starred ${ts}")`) })
.first();
await expect(starredItem.locator('.sidebar__page-icon--favorite')).toBeVisible();
const plainItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("Fav Plain ${ts}")`) })
.first();
await expect(plainItem.locator('.sidebar__page-icon--favorite')).toHaveCount(0);
// The latching filter narrows the sidebar to favorites only.
const filter = page.locator('.sidebar__view-btn--favorites');
await filter.click();
await expect(filter).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator(`.sidebar__page:text-is("Fav Starred ${ts}")`)).toBeVisible();
await expect(page.locator(`.sidebar__page:text-is("Fav Plain ${ts}")`)).toHaveCount(0);
await filter.click();
await expect(page.locator(`.sidebar__page:text-is("Fav Plain ${ts}")`)).toBeVisible();
// Unstar → the golden icon disappears.
await star.click();
await expect(star).toHaveAttribute('aria-pressed', 'false');
await expect(starredItem.locator('.sidebar__page-icon--favorite')).toHaveCount(0);
await context.close();
});
test('favorites are personal: another user does not see my star', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await json<{ id: string; slug: string }>(owner, '/api/v1/ponds', {
name: `Fav Personal ${ts}`,
});
const target = await json<{ id: string; slug: string }>(owner, `/api/v1/ponds/${pond.id}/pages`, {
title: `Fav Mine ${ts}`,
});
// Grant the fixture viewer read access via the grants API (never direct
// DB writes — the permission cache must see the change).
const viewerContext = await contextForUser(browser, BASE_URL, 'fixture-viewer');
const meRes = await viewerContext.request.get('/api/v1/auth/me');
const viewerId = ((await meRes.json()) as { id: string }).id;
await json(owner, `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'user',
subjectId: viewerId,
role: 'reader',
scopeType: 'pond',
effect: 'allow',
});
// Owner stars the page.
const ownerPage = await owner.newPage();
await ownerPage.goto(`/p/${pond.slug}/${target.slug}`);
await ownerPage.locator('.editor-page__favorite-toggle').click();
await expect(ownerPage.locator('.editor-page__favorite-toggle')).toHaveAttribute(
'aria-pressed',
'true',
);
// The viewer sees the page, but no star and no golden icon.
const viewerPage = await viewerContext.newPage();
await viewerPage.goto(`/p/${pond.slug}/${target.slug}`);
await expect(viewerPage.locator('.editor-page__favorite-toggle')).toHaveAttribute(
'aria-pressed',
'false',
);
await expect(viewerPage.locator('.sidebar__page-icon--favorite')).toHaveCount(0);
await owner.close();
await viewerContext.close();
});