feat(activity): "Gjort" mark with statistics
Per-user "I've done this" toggle alongside hearts and bookmarks.
Hearts express approval; gjort expresses completion. Both contribute
to public statistics so readers can see what people LIKE versus what
people actually DO.
Backend:
- New activity_done table (composite PK on activity_id + user_id,
CASCADE on both refs, mirrors activity_hearts).
- POST/DELETE /api/activities/:id/done. Unlike heart/bookmark, "gjort"
works on every visibility the viewer can see — private (owner-only,
acts as a personal todo checkbox), friends-only (mutual-friend +
no-block check, mirrors GET /:id), public, semi. Non-viewers get
404 to avoid leaking existence.
- buildBulkLookups + serialize extended with done_count + viewer_done
so the list endpoint stays at constant queries per render.
- Public-list endpoint (server/users.ts) bulk-fetches done counts
alongside heart counts; viewer_done is always false (unauth view).
Types: Activity{Public,Semi,Private,Friends} all gain done_count +
viewer_done. Private's count is at most 1 (only the owner can write).
UI: new "✓ Gjort" / "☐ Gjort" button in the action row with the same
optimistic-toggle + localOverride pattern as hearts. Anonymous viewers
on public activities see a muted "✓ N" stat. Title hint clarifies
the intent: "Dette har jeg gjort" vs "Du har gjort dette."
Tests: 2 new in engagement.test.ts — toggle + idempotency on public,
owner-only access on private (non-owner gets 404).
This commit is contained in:
parent
38db772b4f
commit
bbb5ad2bdd
7 changed files with 263 additions and 17 deletions
|
|
@ -152,6 +152,33 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- "Gjort" (completion mark) ------------------------------------------
|
||||
// Works on EVERY visibility the viewer can see. For private rows it acts
|
||||
// as a personal checkbox; for public/semi/friends it contributes to the
|
||||
// visible done_count statistic.
|
||||
let doneBusy = $state(false);
|
||||
async function toggleDone() {
|
||||
if (!session.user || doneBusy) return;
|
||||
doneBusy = true;
|
||||
const wasDone = view.viewer_done;
|
||||
localOverride = {
|
||||
...view,
|
||||
viewer_done: !wasDone,
|
||||
done_count: view.done_count + (wasDone ? -1 : 1),
|
||||
};
|
||||
try {
|
||||
const updated = wasDone
|
||||
? await api.undoneActivity(view.id)
|
||||
: await api.doneActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
doneBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bookmarks -----------------------------------------------------------
|
||||
let bookmarkBusy = $state(false);
|
||||
async function toggleBookmark() {
|
||||
|
|
@ -327,6 +354,23 @@
|
|||
<span class="muted" aria-label="Antall hjerter">♡ {view.heart_count}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- "Gjort" works on every visibility — including the owner's private
|
||||
rows where it's a personal todo checkbox. -->
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleDone}
|
||||
disabled={doneBusy}
|
||||
aria-pressed={view.viewer_done}
|
||||
aria-label={view.viewer_done ? 'Marker som ikke gjort' : 'Marker som gjort'}
|
||||
title={view.viewer_done ? 'Du har gjort dette' : 'Dette har jeg gjort'}
|
||||
class={view.viewer_done ? 'primary' : ''}
|
||||
>
|
||||
{view.viewer_done ? '✓ Gjort' : '☐ Gjort'} {view.done_count > 0 ? view.done_count : ''}
|
||||
</button>
|
||||
{:else if view.visibility !== 'private' && view.done_count > 0}
|
||||
<span class="muted" aria-label="Antall som har gjort dette">✓ {view.done_count}</span>
|
||||
{/if}
|
||||
{#if canEdit && onEdit}
|
||||
<button type="button" onclick={startEdit}>Rediger</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ export const api = {
|
|||
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'POST' }),
|
||||
unheartActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/heart`, { method: 'DELETE' }),
|
||||
doneActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
|
||||
undoneActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
|
||||
bookmarkActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
|
||||
unbookmarkActivity: (id: string) =>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,20 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
|
|||
.get(activityId, viewerId);
|
||||
}
|
||||
|
||||
/** Done-count + viewer-done lookup for a single activity. */
|
||||
function doneFor(activityId: string, viewerId: string | null): { count: number; done: boolean } {
|
||||
const db = getDb();
|
||||
const count = (db
|
||||
.prepare('SELECT COUNT(*) AS n FROM activity_done WHERE activity_id = ?')
|
||||
.get(activityId) as { n: number }).n;
|
||||
const done = viewerId
|
||||
? !!db
|
||||
.prepare('SELECT 1 FROM activity_done WHERE activity_id = ? AND user_id = ?')
|
||||
.get(activityId, viewerId)
|
||||
: false;
|
||||
return { count, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public-facing attribution for an owner. Prefer the user's chosen
|
||||
* `display_name`; fall back to their `username` slug if set (also user-chosen);
|
||||
|
|
@ -125,6 +139,7 @@ function b64ToBuf(s: string): Buffer {
|
|||
interface BulkLookups {
|
||||
tags: Map<string, string[]>;
|
||||
hearts: Map<string, { count: number; hearted: boolean }>;
|
||||
done: Map<string, { count: number; done: boolean }>;
|
||||
bookmarked: Set<string>;
|
||||
attribution: Map<string, { display: string | null; username: string | null }>;
|
||||
}
|
||||
|
|
@ -134,10 +149,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
const ids = rows.map((r) => r.id);
|
||||
const tags = bulkTagsFor(ids);
|
||||
const hearts = new Map<string, { count: number; hearted: boolean }>();
|
||||
const done = new Map<string, { count: number; done: boolean }>();
|
||||
const bookmarked = new Set<string>();
|
||||
const attribution = new Map<string, { display: string | null; username: string | null }>();
|
||||
|
||||
if (ids.length === 0) return { tags, hearts, bookmarked, attribution };
|
||||
if (ids.length === 0) return { tags, hearts, done, bookmarked, attribution };
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
|
||||
const heartCounts = db
|
||||
|
|
@ -161,6 +177,27 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
hearts.set(r.activity_id, { count: r.n, hearted: viewerHeartSet.has(r.activity_id) });
|
||||
}
|
||||
|
||||
const doneCounts = db
|
||||
.prepare(`
|
||||
SELECT activity_id, COUNT(*) AS n FROM activity_done
|
||||
WHERE activity_id IN (${ph})
|
||||
GROUP BY activity_id
|
||||
`)
|
||||
.all(...ids) as { activity_id: string; n: number }[];
|
||||
const viewerDoneSet = new Set<string>();
|
||||
if (viewerId) {
|
||||
const vd = db
|
||||
.prepare(`
|
||||
SELECT activity_id FROM activity_done
|
||||
WHERE activity_id IN (${ph}) AND user_id = ?
|
||||
`)
|
||||
.all(...ids, viewerId) as { activity_id: string }[];
|
||||
for (const r of vd) viewerDoneSet.add(r.activity_id);
|
||||
}
|
||||
for (const r of doneCounts) {
|
||||
done.set(r.activity_id, { count: r.n, done: viewerDoneSet.has(r.activity_id) });
|
||||
}
|
||||
|
||||
if (viewerId) {
|
||||
const bm = db
|
||||
.prepare(`
|
||||
|
|
@ -194,7 +231,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
|
|||
}
|
||||
}
|
||||
|
||||
return { tags, hearts, bookmarked, attribution };
|
||||
return { tags, hearts, done, bookmarked, attribution };
|
||||
}
|
||||
|
||||
function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity {
|
||||
|
|
@ -202,6 +239,11 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
// otherwise -created_at so unsorted rows float to the top by recency.
|
||||
// Matches the SQL ORDER BY in the list query.
|
||||
const sortPos = row.sort_position ?? -row.created_at;
|
||||
// Done state applies to all visibilities. For private rows the count is
|
||||
// at most 1 (only the owner can mark it) and acts as a personal checkbox.
|
||||
const done = bulk
|
||||
? (bulk.done.get(row.id) ?? { count: 0, done: false })
|
||||
: doneFor(row.id, viewerId);
|
||||
if (row.visibility === 'private') {
|
||||
const a: ActivityPrivate = {
|
||||
id: row.id,
|
||||
|
|
@ -213,6 +255,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: 0,
|
||||
viewer_hearted: false,
|
||||
viewer_bookmarked: false,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -243,6 +287,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -274,6 +320,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -295,6 +343,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups
|
|||
heart_count: hearts.count,
|
||||
viewer_hearted: hearts.hearted,
|
||||
viewer_bookmarked: bookmarked,
|
||||
done_count: done.count,
|
||||
viewer_done: done.done,
|
||||
sort_position: sortPos,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
|
|
@ -602,6 +652,61 @@ activitiesRoutes.delete('/:id/heart', requireAuth, (c) => toggleMark(c, 'heart'
|
|||
activitiesRoutes.post('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'add'));
|
||||
activitiesRoutes.delete('/:id/bookmark', requireAuth, (c) => toggleMark(c, 'bookmark', 'remove'));
|
||||
|
||||
/**
|
||||
* "Gjort" / done toggle. Differs from heart/bookmark:
|
||||
* - Private rows are allowed (owner-only, acts as a personal checkbox).
|
||||
* - Friends-only rows require the viewer to be the owner OR a mutual
|
||||
* friend with no block in either direction.
|
||||
* Same visibility rules as GET /api/activities/:id — we treat
|
||||
* "can't see it → 404" the same way.
|
||||
*/
|
||||
function toggleDone(c: AppContext, op: 'add' | 'remove') {
|
||||
const userId = c.get('userId');
|
||||
const id = c.req.param('id');
|
||||
if (!id) return c.json({ error: 'not_found' }, 404);
|
||||
const db = getDb();
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
|
||||
.get(id) as { visibility: Visibility; owner_id: string } | null;
|
||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
||||
|
||||
// Apply the same visibility filter as the list and single-fetch endpoints.
|
||||
// Hidden rows return 404 (not 403) so the endpoint doesn't double as an
|
||||
// existence oracle.
|
||||
if (row.visibility === 'private' && row.owner_id !== userId) {
|
||||
return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
if (row.visibility === 'friends' && row.owner_id !== userId) {
|
||||
const isFriend = !!db
|
||||
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
|
||||
.get(row.owner_id, userId);
|
||||
if (!isFriend) return c.json({ error: 'not_found' }, 404);
|
||||
const blocked = !!db
|
||||
.prepare(`
|
||||
SELECT 1 FROM user_blocks
|
||||
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||
OR (blocker_id = ? AND blocked_id = ?)
|
||||
`)
|
||||
.get(row.owner_id, userId, userId, row.owner_id);
|
||||
if (blocked) return c.json({ error: 'not_found' }, 404);
|
||||
}
|
||||
|
||||
if (op === 'add') {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO activity_done (activity_id, user_id, created_at) VALUES (?, ?, ?)',
|
||||
).run(id, userId, Date.now());
|
||||
} else {
|
||||
db.prepare('DELETE FROM activity_done WHERE activity_id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
const refreshed = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow;
|
||||
return c.json(serialize(refreshed, userId));
|
||||
}
|
||||
|
||||
activitiesRoutes.post('/:id/done', requireAuth, (c) => toggleDone(c, 'add'));
|
||||
activitiesRoutes.delete('/:id/done', requireAuth, (c) => toggleDone(c, 'remove'));
|
||||
|
||||
// --- DELETE /api/activities/:id ---------------------------------------------
|
||||
// Authz:
|
||||
// - private: owner only. Other users can't even see private rows, so
|
||||
|
|
|
|||
12
server/db.ts
12
server/db.ts
|
|
@ -119,6 +119,18 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
PRIMARY KEY (activity_id, user_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activity_hearts_user_idx ON activity_hearts(user_id)`,
|
||||
// "Gjort": per-user completion mark. Same shape as hearts but a different
|
||||
// meaning — hearts express approval, gjort expresses "I actually did
|
||||
// this." Unlike hearts/bookmarks, gjort applies to ANY visibility the
|
||||
// viewer can see, including their own private activities (where it
|
||||
// doubles as a todo-list checkbox).
|
||||
`CREATE TABLE IF NOT EXISTS activity_done (
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (activity_id, user_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS activity_done_user_idx ON activity_done(user_id)`,
|
||||
// Bookmarks: logged-in users can save public/semi activities to their own
|
||||
// dashboard. Same shape as hearts: composite PK on (user, activity), one
|
||||
// row per bookmark. CASCADE on the activity so deletes clean up.
|
||||
|
|
|
|||
|
|
@ -51,22 +51,24 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
`)
|
||||
.all(user.id) as ActivityRow[];
|
||||
|
||||
// Bulk lookups so we make 2 queries instead of 2N. The endpoint is public,
|
||||
// so viewer_hearted/bookmarked are always false — no per-viewer queries.
|
||||
// Bulk lookups so we make 3 queries instead of 3N. The endpoint is public,
|
||||
// so viewer_hearted/bookmarked/done are always false — no per-viewer queries.
|
||||
const ids = rows.map((r) => r.id);
|
||||
const tags = bulkTagsFor(ids);
|
||||
const heartCounts = ids.length === 0
|
||||
? new Map<string, number>()
|
||||
: new Map<string, number>(
|
||||
(db
|
||||
.prepare(`
|
||||
SELECT activity_id, COUNT(*) AS n FROM activity_hearts
|
||||
WHERE activity_id IN (${ids.map(() => '?').join(',')})
|
||||
GROUP BY activity_id
|
||||
`)
|
||||
.all(...ids) as { activity_id: string; n: number }[]
|
||||
).map((r) => [r.activity_id, r.n]),
|
||||
);
|
||||
const bulkCounts = (table: string): Map<string, number> => {
|
||||
if (ids.length === 0) return new Map();
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT activity_id, COUNT(*) AS n FROM ${table}
|
||||
WHERE activity_id IN (${ph})
|
||||
GROUP BY activity_id
|
||||
`)
|
||||
.all(...ids) as { activity_id: string; n: number }[];
|
||||
return new Map(rows.map((r) => [r.activity_id, r.n]));
|
||||
};
|
||||
const heartCounts = bulkCounts('activity_hearts');
|
||||
const doneCounts = bulkCounts('activity_done');
|
||||
|
||||
const activities: ActivityPublic[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
|
@ -87,9 +89,11 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
scheduled_at: r.scheduled_at,
|
||||
heart_count: heartCounts.get(r.id) ?? 0,
|
||||
// The public-list endpoint is unauthenticated; we don't know who the
|
||||
// viewer is to fill viewer_hearted/bookmarked truthfully. Always false.
|
||||
// viewer is to fill viewer_hearted/bookmarked/done truthfully. Always false.
|
||||
viewer_hearted: false,
|
||||
viewer_bookmarked: false,
|
||||
done_count: doneCounts.get(r.id) ?? 0,
|
||||
viewer_done: false,
|
||||
// No personal sort here — anonymous view always sorts by recency.
|
||||
sort_position: -r.created_at,
|
||||
created_at: r.created_at,
|
||||
|
|
|
|||
|
|
@ -219,6 +219,10 @@ export interface ActivityPublic {
|
|||
viewer_hearted: boolean;
|
||||
/** True when the authenticated viewer has bookmarked this activity. */
|
||||
viewer_bookmarked: boolean;
|
||||
/** Total "done" marks (people who have actually completed the activity). */
|
||||
done_count: number;
|
||||
/** True when the authenticated viewer has marked this activity done. */
|
||||
viewer_done: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -244,6 +248,8 @@ export interface ActivitySemi {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -264,6 +270,11 @@ export interface ActivityPrivate {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
// "Done" DOES apply to private rows: the owner can use it as a personal
|
||||
// todo checkbox. done_count is therefore always 0 or 1 (just the owner)
|
||||
// for private rows. viewer_done reflects the owner's own state.
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
@ -294,6 +305,8 @@ export interface ActivityFriends {
|
|||
heart_count: number;
|
||||
viewer_hearted: boolean;
|
||||
viewer_bookmarked: boolean;
|
||||
done_count: number;
|
||||
viewer_done: boolean;
|
||||
/** Effective sort position for THIS viewer: a custom value if they've
|
||||
* dragged this row, otherwise -created_at so unsorted/new rows float
|
||||
* to the top. Used by the client to compute drop-target midpoints. */
|
||||
|
|
|
|||
|
|
@ -124,6 +124,70 @@ describe('hearts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('"gjort" (done) marks', () => {
|
||||
ttest('toggle on a public activity, idempotent both ways', async () => {
|
||||
const [owner, viewer] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'done-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'done-viewer@test.invalid'),
|
||||
]);
|
||||
const pub = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'public', title: 'Gjort-test', tags: [],
|
||||
});
|
||||
|
||||
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(after.done_count).toBe(1);
|
||||
expect(after.viewer_done).toBe(true);
|
||||
|
||||
// Re-mark is idempotent (INSERT OR IGNORE).
|
||||
const again = await reqJson<Activity>(ctx, 'POST', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(again.done_count).toBe(1);
|
||||
|
||||
// Owner's own view shows the count too but their viewer_done is still false.
|
||||
const list = await listActivities(ctx, owner.cookie);
|
||||
const ownerView = list.find((a) => a.id === pub.id);
|
||||
expect(ownerView?.done_count).toBe(1);
|
||||
expect(ownerView?.viewer_done).toBe(false);
|
||||
|
||||
// Undo.
|
||||
const undone = await reqJson<Activity>(ctx, 'DELETE', `/api/activities/${pub.id}/done`, {
|
||||
cookie: viewer.cookie,
|
||||
});
|
||||
expect(undone.done_count).toBe(0);
|
||||
expect(undone.viewer_done).toBe(false);
|
||||
});
|
||||
|
||||
ttest('owner can mark their own private activity done; non-owners get 404', async () => {
|
||||
const [owner, other] = await Promise.all([
|
||||
signupAndGetCookie(ctx, 'priv-done-owner@test.invalid'),
|
||||
signupAndGetCookie(ctx, 'priv-done-other@test.invalid'),
|
||||
]);
|
||||
// Private rows need a ciphertext/nonce payload — we don't actually
|
||||
// decrypt in this test, just need the row to exist.
|
||||
const priv = await createActivity(ctx, owner.cookie, {
|
||||
visibility: 'private',
|
||||
ciphertext: 'AAAA',
|
||||
nonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', // 24 bytes base64
|
||||
} as never);
|
||||
|
||||
// Owner can mark it done.
|
||||
const after = await reqJson<Activity>(ctx, 'POST', `/api/activities/${priv.id}/done`, {
|
||||
cookie: owner.cookie,
|
||||
});
|
||||
expect(after.done_count).toBe(1);
|
||||
expect(after.viewer_done).toBe(true);
|
||||
|
||||
// Non-owner gets 404 (same as GET /:id behaviour — doesn't leak existence).
|
||||
const denied = await req(ctx, 'POST', `/api/activities/${priv.id}/done`, {
|
||||
cookie: other.cookie,
|
||||
});
|
||||
expect(denied.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookmarks', () => {
|
||||
ttest('toggle, idempotent, refused on private', async () => {
|
||||
const [owner, viewer, otherViewer] = await Promise.all([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue