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
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
117 lines
4.7 KiB
TypeScript
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();
|
|
});
|