Skip to content

Commit 48a9d9d

Browse files
fix(app-shell): a member takes back their own comment reaction through the unreact door (#26384)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9TSbb9RiQLSUNmAoRYsMSCQ)_ Fixes #26346 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:记录动态里点自己的回应撤回时,改走服务端新开的「撤回自己回应」专用接口 `DELETE /api/v1/comments/reactions/:id`,不再走通用数据删除接口;撤回按钮的权限判断不再要求 `delete` 授权。 - **为什么改**:普通成员(只有全员默认授权、没有职位)能加回应却永远撤不回:通用删除要对象级 `delete` 授权,而全员授权集按 ADR-0090 D5 不能带它。服务端那一半已在 PR #26345 落地。 - **风险与回滚**:风险低,只动回应芯片的撤回一条写路径和地图里一行;别人的回应仍然不会出现撤回(点了是加自己的)。回滚即 revert 本 PR,旧路径回来,普通成员重新撤不回。 - **先看**:① `ui/packages/app-shell/src/views/RecordDetailView.tsx` 的 `handleToggleReaction` 里 `write` 的撤回分支;② `ui/packages/core/src/utils/affordanceGrants.ts` 的 `feedReactionRemove` 行;③ `RecordDetailView.feedGrant-12082.test.tsx` 新增的 describe 块。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx`,预期全绿,其中 "a member with no grant beyond create on sys_comment_reaction adds a reaction and takes it back" 断言请求打到 `/api/v1/comments/reactions/new-row`、方法 DELETE。 - **需要你做的**:无,席位落地。 ## What changes - **The take-back's door.** On the records store, a click on the caller's own reaction chip now removes the row through the unreact door, `DELETE /api/v1/comments/reactions/:id` (mounted by `@objectstack/plugin-audit`, server half in #26345), called through the page's existing authenticated fetch and `VITE_SERVER_URL` base, the way the record page already reaches non-data routes. No body and no query parameter are sent. A non-2xx answer is a refused write: the existing settle step puts the reaction back and raises the one "Your reaction was not saved" toast. The generic `DELETE /api/v1/data/sys_comment_reaction/:id` is no longer called from the feed. No `@objectstack/client` method is added. - **The affordance row.** `feedReactionRemove` in `AFFORDANCE_GRANTS` changes value from `{ crud: 'delete', grant: 'delete' }` to `{ crud: null, grant: 'create' }`. `crud: null` because the write no longer goes through the object's data door (the same meaning the attachment rows carry); `grant: 'create'` because the take-back is offered to whoever may react. The row's TYPE keeps its shape (`AffordanceGrantRow`, same two keys) and `ConsoleAffordance` keeps the same member set; no export is added or removed. - **Mine.** Which chip offers a take-back is unchanged: the click handler already takes back only when the caller's id is among the emoji's reactors, and removes only the row id the read or the create recorded for the caller (`groupReactionRows` keys on the row's server-stamped `user_id`). A click on another member's chip adds the caller's own reaction. - **Docs.** `ui/content/docs/plugins/plugin-detail.mdx` no longer says a take-back needs the delete grant. - **sdui record.** None. The per-package records were retired by #26367 while this PR was open. The main merge took the modify/delete with `git rm`, and CI's Build & E2E UI regenerates the manifest (`sdui.manifest.json` unchanged: 106 components). ## Tests New regression cases (in `RecordDetailView.feedGrant-12082.test.tsx`, describe "a take-back goes through the unreact door, and only on the caller's own reaction"): - a member with only the create grant on `sys_comment_reaction` adds 🎉 and clicks it again: one call to `/api/v1/comments/reactions/new-row` with `{ method: 'DELETE', credentials: 'include' }`, no data-door delete, and the chip goes away; - another member's 👍 chip: the click creates the caller's own row, and the unreact door is never called; - the door answers 403: the reaction is put back (count 1) and `toast.error` is raised once. Forced test edits: 1. `ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx` — the authenticated-fetch mock now returns a hoisted mock (it returned a bare `vi.fn()`); "a take-back asks the delete grant … writes nothing" becomes "a take-back reads no delete grant … removes it through the unreact door"; the full-grant CONTROL and the registered-schema case assert the door's URL instead of `dataSource.delete(sys_comment_reaction, r1)`. 2. `ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx` — `EXPECTED_GRANT.feedReactionRemove` is `'create'`; the write-census entry `RecordDetailView > handleToggleReaction > write :: delete` is deleted, since that site no longer calls a `DataSource` write (the census states it does not see a write through its own route, the same as the attachment upload). 3. `ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx` — not named in the dispatch; forced because its fake server asserted the take-back as `delete(sys_comment_reaction, id)`. The fake server now also models the unreact door per member (removes the member's own row, `403` on anyone else's), and the two take-back cases assert the removed ids at the door and that `delete` is not called. ## Acceptance notes - The door authorizes on the row's `created_by`; the chip decides "mine" on the row's `user_id`. Both are stamped server-side from the reacting session at create (the create hook stamps `user_id`, the engine stamps `created_by`), so they name the same member for every row made through the data door. Keying the chip on `created_by` was not done here (it would move the read's grouping too); noted for the seat. - The `feedReactionRemove` row now asks the same grant as `feedReactionAdd`, and the reaction control is only handed over when `feedReactionAdd` allows it, so on the records store the take-back is in practice decided by ownership alone, as the card asks. ## Verification <details><summary>Tests and ablation</summary> Commands and readings are in the os-dev-report on #26346, taken at this PR's head. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmPysaCwFvT4Pqb88z8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 014b968 commit 48a9d9d

7 files changed

Lines changed: 190 additions & 44 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/core': patch
4+
---
5+
6+
fix(app-shell): a member takes back their own comment reaction through the unreact door, with no object delete grant
7+
8+
A member whose grants are the every-member ones could add a reaction on the record feed and never take it back. The reaction chip removed through the generic data door's `DELETE /api/v1/data/sys_comment_reaction/:id`, which needs an object-level `delete` grant that no every-member set may carry (ADR-0090 D5), and the `feedReactionRemove` row of the affordance-to-grant map read that grant, so the chip offered no take-back at all.
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
- The record page now removes the caller's own reaction row through the unreact door, `DELETE /api/v1/comments/reactions/:id`, which `@objectstack/plugin-audit` mounts. The door removes only the caller's own row; a refused take-back puts the reaction back on screen and says it was not saved, as before.
14+
- `feedReactionRemove` in `@object-ui/core`'s `AFFORDANCE_GRANTS` now reads `{ crud: null, grant: 'create' }`: its write no longer goes through the data door, so no CRUD bit is read, and it asks the grant the reaction was added under. Whose reaction a chip shows still decides which chips offer the take-back: a click on another member's reaction adds the caller's own.
15+
- A member who holds the `delete` grant sees the same behaviour: the take-back goes through the same door.

‎ui/content/docs/plugins/plugin-detail.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,10 @@ Three sources, in precedence order:
222222
hands each handler over only where the caller may make that write
223223
(objectui#12082): the composer and the reply input need the **create** grant
224224
on `sys_comment`; the reaction control needs the **create** grant on
225-
`sys_comment_reaction`, and taking a reaction back its **delete** grant; on a
225+
`sys_comment_reaction`, and taking the caller's own reaction back needs the
226+
same grant: it goes through the unreact door
227+
(`DELETE /api/v1/comments/reactions/:id`), which removes only the caller's
228+
own row and asks no **delete** grant. On a
226229
deployment with no `sys_comment_reaction` the control needs the **update**
227230
grant on `sys_comment`. These are the `feedComment`, `feedReactionAdd`,
228231
`feedReactionRemove` and `feedReactionColumn` rows of the affordance-to-grant

‎ui/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx‎

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
*
1717
* - the composer: `feedComment`, the create grant on `sys_comment`;
1818
* - the reaction control: `feedReactionAdd`, the create grant on
19-
* `sys_comment_reaction`, with a take-back asking `feedReactionRemove`, its
20-
* delete grant, per click;
19+
* `sys_comment_reaction`, with a take-back asking `feedReactionRemove` per
20+
* click: the same create grant, since the take-back goes through the
21+
* unreact door (`DELETE /api/v1/comments/reactions/:id`), which removes the
22+
* caller's own row with no `delete` grant;
2123
* - on a deployment with no `sys_comment_reaction` (the column store), the
2224
* reaction control: `feedReactionColumn`, the update grant on `sys_comment`.
2325
*
@@ -36,10 +38,13 @@ import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/pe
3638
import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail';
3739
import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data';
3840

41+
/** The page's authenticated fetch: the unreact door is called through it. */
42+
const authFetch = vi.hoisted(() => vi.fn());
43+
3944
vi.mock('@object-ui/auth', async (importOriginal) => ({
4045
...(await importOriginal<Record<string, unknown>>()),
4146
useAuth: () => ({ user: { id: 'u1', name: 'u1', image: null }, activeOrganization: null }),
42-
createAuthenticatedFetch: () => vi.fn(),
47+
createAuthenticatedFetch: () => authFetch,
4348
}));
4449

4550
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
@@ -69,6 +74,7 @@ vi.mock('./MetadataInspector', () => ({
6974
}));
7075

7176
import { RecordDetailView } from './RecordDetailView';
77+
import { toast } from 'sonner';
7278

7379
const COMMENT = 'sys_comment';
7480
const REACTION = 'sys_comment_reaction';
@@ -127,18 +133,21 @@ function answer(feed: { [COMMENT]?: Bits; [REACTION]?: Bits }): MePermissionsRes
127133
} as MePermissionsResponse;
128134
}
129135

130-
/** One comment by another member; the caller (`u1`) has already reacted 👍 on it. */
131-
function makeDataSource() {
136+
/**
137+
* One comment by another member; `reactor` (the caller `u1` unless named) has
138+
* already reacted 👍 on it.
139+
*/
140+
function makeDataSource(reactor = 'u1') {
132141
const comment = {
133142
id: COMMENT_ID,
134143
thread_id: THREAD,
135144
author_id: 'u3',
136145
author_name: 'u3',
137146
body: COMMENT_BODY,
138147
created_at: '2026-10-10T08:00:00.000Z',
139-
reactions: JSON.stringify({ '👍': ['u1'] }),
148+
reactions: JSON.stringify({ '👍': [reactor] }),
140149
};
141-
const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: 'u1', created_at: '2026-10-10T09:00:00.000Z' };
150+
const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: reactor, created_at: '2026-10-10T09:00:00.000Z' };
142151
return {
143152
find: vi.fn(async (objectName: string) => {
144153
if (objectName === COMMENT) return { data: [{ ...comment }] };
@@ -210,7 +219,23 @@ const thumbsChip = (view: View) =>
210219
(b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '👍',
211220
);
212221

222+
/** The unreact door's answer to a removal of the caller's own row. */
223+
const unreacted = (id: string) =>
224+
new Response(JSON.stringify({ success: true, data: { id, deleted: true } }), {
225+
status: 200,
226+
headers: { 'content-type': 'application/json' },
227+
});
228+
229+
/** The calls that reached the unreact door: `[url, init]`. */
230+
const unreactCalls = () =>
231+
authFetch.mock.calls.filter(([url]) => String(url).includes('/api/v1/comments/reactions/'));
232+
213233
beforeEach(() => {
234+
authFetch.mockImplementation(async (url: string) =>
235+
String(url).includes('/api/v1/comments/reactions/')
236+
? unreacted(String(url).split('/').pop() as string)
237+
: new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
238+
);
214239
vi.stubGlobal(
215240
'fetch',
216241
vi.fn(async () =>
@@ -267,19 +292,22 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio
267292
expect((thumbsChip(view) as HTMLButtonElement).disabled).toBe(!offered);
268293
});
269294

270-
it('a take-back asks the delete grant: with create but not delete, clicking the own reaction writes nothing and keeps it shown', async () => {
295+
it('a take-back reads no delete grant: with create but not delete, clicking the own reaction removes it through the unreact door', async () => {
271296
const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly }));
272297
fireEvent.click(thumbsChip(view)!);
273298
await act(async () => {});
299+
expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/r1', { method: 'DELETE', credentials: 'include' }]]);
274300
expect(dataSource.delete).not.toHaveBeenCalled();
275-
expect(thumbsChip(view)!.children[1].textContent).toBe('1');
301+
expect(thumbsChip(view)).toBeUndefined();
276302
});
277303

278-
it('CONTROL — with the delete grant the same click takes the reaction back by deleting the own row', async () => {
304+
it('CONTROL — with the delete grant the same click takes the reaction back through the same door, never the data door', async () => {
279305
const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.full }));
280306
fireEvent.click(thumbsChip(view)!);
281307
await act(async () => {});
282-
expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]);
308+
expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/r1', { method: 'DELETE', credentials: 'include' }]]);
309+
expect(dataSource.delete).not.toHaveBeenCalled();
310+
expect(thumbsChip(view)).toBeUndefined();
283311
});
284312

285313
it('adding through the picker creates the own row where create is held', async () => {
@@ -291,6 +319,51 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio
291319
});
292320
});
293321

322+
describe("a take-back goes through the unreact door, and only on the caller's own reaction (objectstack#26346)", () => {
323+
it('a member with no grant beyond create on sys_comment_reaction adds a reaction and takes it back', async () => {
324+
const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.readOnly, [REACTION]: SHAPE.createOnly }));
325+
fireEvent.click(addReaction(view)!);
326+
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: '🎉' }));
327+
await act(async () => {});
328+
const party = () =>
329+
(view.queryAllByRole('button') as HTMLElement[]).find(
330+
(b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '🎉',
331+
);
332+
expect(party()).toBeDefined();
333+
fireEvent.click(party()!);
334+
await act(async () => {});
335+
expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '🎉' }]]);
336+
expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/new-row', { method: 'DELETE', credentials: 'include' }]]);
337+
expect(dataSource.delete).not.toHaveBeenCalled();
338+
expect(party()).toBeUndefined();
339+
});
340+
341+
it("another member's reaction offers no take-back: a click on their chip adds the caller's own row and reaches no door that removes", async () => {
342+
const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.full }), makeDataSource('u2'));
343+
fireEvent.click(thumbsChip(view)!);
344+
await act(async () => {});
345+
expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '👍' }]]);
346+
expect(unreactCalls()).toEqual([]);
347+
expect(dataSource.delete).not.toHaveBeenCalled();
348+
expect(thumbsChip(view)!.children[1].textContent).toBe('2');
349+
});
350+
351+
it('a take-back the door refuses puts the reaction back and says it was not saved', async () => {
352+
authFetch.mockImplementation(async () =>
353+
new Response(JSON.stringify({ success: false, error: { code: 'PERMISSION_DENIED' } }), {
354+
status: 403,
355+
headers: { 'content-type': 'application/json' },
356+
}),
357+
);
358+
const { view } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly }));
359+
fireEvent.click(thumbsChip(view)!);
360+
await act(async () => {});
361+
expect(unreactCalls().map(([url]) => url)).toEqual(['/api/v1/comments/reactions/r1']);
362+
expect(thumbsChip(view)!.children[1].textContent).toBe('1');
363+
expect(toast.error).toHaveBeenCalledTimes(1);
364+
});
365+
});
366+
294367
/**
295368
* The two feed objects as objectstack registers them (`plugin-audit`): their
296369
* policy keys (`managedBy`, `userActions`) and the `enable` block the server
@@ -336,7 +409,8 @@ describe('against the registered feed schemas in the page objects (objectui#1208
336409
expect(addReaction(view)).not.toBeNull();
337410
fireEvent.click(thumbsChip(view)!);
338411
await act(async () => {});
339-
expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]);
412+
expect(unreactCalls().map(([url]) => url)).toEqual(['/api/v1/comments/reactions/r1']);
413+
expect(dataSource.delete).not.toHaveBeenCalled();
340414
});
341415
});
342416

‎ui/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx‎

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* left only the later write stored. Now, where the deployment has the object,
1111
* the chatter reads every comment's reaction rows in one batched `comment_id`
1212
* `$in` read, groups them into the shape the panel already renders, and a click
13-
* creates or deletes the clicker's own row. Where the deployment has no such
13+
* creates the clicker's own row, or removes it through the unreact door
14+
* (`DELETE /api/v1/comments/reactions/:id`, objectstack#26346). Where the deployment has no such
1415
* object (a framework that predates it), the column path stays, unchanged: the
1516
* `objectui#11019` / `objectui#10899` / `objectui#11035` pins beside this file
1617
* run on that path.
@@ -20,7 +21,8 @@
2021
* source is a fake SERVER shared by every mounted view: each view's adapter is
2122
* bound to its signed-in member, and the server keeps the object's declared
2223
* rules: `user_id` stamped from that member, one row per (comment, emoji,
23-
* user), and a member deletes only their own row. Which store the page uses is
24+
* user), and a member removes only their own row, through the unreact door
25+
* the page's authenticated fetch reaches. Which store the page uses is
2426
* read from the object registry the page's metadata context serves.
2527
*/
2628

@@ -36,6 +38,9 @@ import { toast } from 'sonner';
3638
/** The signed-in member is read from a context, so two views can be two members at once. */
3739
const signedIn = vi.hoisted(() => ({ ctx: null as unknown as React.Context<string> }));
3840

41+
/** The unreact door of the member a view is being mounted as: the page's authenticated fetch reaches it. */
42+
const mounting = vi.hoisted(() => ({ door: null as unknown as (url: string, init?: RequestInit) => Promise<Response> }));
43+
3944
vi.mock('@object-ui/auth', async (importOriginal) => {
4045
const ReactModule = await import('react');
4146
signedIn.ctx = ReactModule.createContext('u1');
@@ -45,7 +50,12 @@ vi.mock('@object-ui/auth', async (importOriginal) => {
4550
const id = ReactModule.useContext(signedIn.ctx);
4651
return { user: { id, name: id, image: null }, activeOrganization: null };
4752
},
48-
createAuthenticatedFetch: () => vi.fn(),
53+
// Only the unreact door is answered; every other call answers nothing, as before.
54+
createAuthenticatedFetch: () => {
55+
const door = mounting.door;
56+
return (url: string, init?: RequestInit) =>
57+
String(url).includes('/api/v1/comments/reactions/') ? door(url, init) : undefined;
58+
},
4959
};
5060
});
5161

@@ -136,7 +146,8 @@ const refusal = (status: number, code: string) =>
136146
/**
137147
* One server shared by every view a case mounts. `as(member)` is that member's
138148
* adapter: what they create is stamped as theirs, and they delete only rows
139-
* that are theirs.
149+
* that are theirs. Its `unreact` is that member's unreact door: it removes the
150+
* member's own row and refuses anyone else's, as the server's door does.
140151
*/
141152
function makeServer(init: { comments?: CommentRow[]; reactions?: Omit<ReactionRow, 'id' | 'created_at'>[] } = {}) {
142153
const comments: CommentRow[] = init.comments ?? [
@@ -192,6 +203,18 @@ function makeServer(init: { comments?: CommentRow[]; reactions?: Omit<ReactionRo
192203
reactions.splice(at, 1);
193204
return true;
194205
}),
206+
unreact: vi.fn(async (url: string, init?: RequestInit) => {
207+
const answer = (status: number, body: unknown) =>
208+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
209+
const id = decodeURIComponent(String(url).split('/api/v1/comments/reactions/')[1]);
210+
if (init?.method !== 'DELETE') return answer(404, { success: false });
211+
const at = reactions.findIndex((r) => r.id === id);
212+
if (at < 0) return answer(404, { success: false, error: { code: 'RECORD_NOT_FOUND' } });
213+
// The door's own rule: the row's reactor is the caller.
214+
if (reactions[at].user_id !== member) return answer(403, { success: false, error: { code: 'PERMISSION_DENIED' } });
215+
reactions.splice(at, 1);
216+
return answer(200, { success: true, data: { id, deleted: true } });
217+
}),
195218
});
196219

197220
return { comments, reactions, as };
@@ -234,6 +257,7 @@ function tree(member: string, dataSource: any, metadata: any) {
234257

235258
/** Mounts the record page as `member` and waits for the comment to show. */
236259
async function mountAs(member: string, dataSource: any, objects: unknown[] = REGISTRY.present) {
260+
mounting.door = dataSource.unreact;
237261
const view = render(tree(member, dataSource, metadataFor(objects)));
238262
await within(view.container).findByText(COMMENT_BODY);
239263
// Let the reaction read that follows the comment read land.
@@ -283,6 +307,11 @@ async function click(run: () => void) {
283307
await act(async () => {});
284308
}
285309

310+
/** The row ids a member's take-backs removed through the unreact door, in order. */
311+
function unreacted(dataSource: any): string[] {
312+
return dataSource.unreact.mock.calls.map(([url]: [string]) => decodeURIComponent(url.split('/').pop() as string));
313+
}
314+
286315
/** Every `sys_comment_reaction` read a data source was asked, as its `$in` id lists. */
287316
function reactionReads(dataSource: any): string[][] {
288317
return dataSource.find.mock.calls
@@ -332,7 +361,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
332361
expect(isOwn(again, '👍')).toBe(true);
333362
});
334363

335-
it('a second click removes it: the clicker deletes their own row, by the id the create returned and by the id the read returned', async () => {
364+
it('a second click removes it: the clicker removes their own row through the unreact door, by the id the create returned and by the id the read returned', async () => {
336365
const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '👍', user_id: 'u2' }] });
337366
const u1 = server.as('u1');
338367
const view = await mountAs('u1', u1);
@@ -342,11 +371,12 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
342371
expect(chips(view)).toEqual(['👍 1', '🎉 1']);
343372

344373
await click(() => fireEvent.click(chip(view, '🎉')));
345-
expect(u1.delete.mock.calls).toEqual([[REACTION, created.id]]);
374+
expect(unreacted(u1)).toEqual([created.id]);
375+
expect(u1.delete).not.toHaveBeenCalled();
346376
expect(server.reactions.map((r) => [r.emoji, r.user_id])).toEqual([['👍', 'u2']]);
347377
expect(chips(view)).toEqual(['👍 1']);
348378

349-
// A row the read found: its id is the one the take-back deletes.
379+
// A row the read found: its id is the one the take-back removes.
350380
cleanup();
351381
const before = makeServer({
352382
reactions: [
@@ -359,7 +389,8 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
359389
const read = await mountAs('u1', reader);
360390
expect(chips(read)).toEqual(['👍 2']);
361391
await click(() => fireEvent.click(chip(read, '👍')));
362-
expect(reader.delete.mock.calls).toEqual([[REACTION, mine.id]]);
392+
expect(unreacted(reader)).toEqual([mine.id]);
393+
expect(reader.delete).not.toHaveBeenCalled();
363394
expect(before.reactions.map((r) => r.user_id)).toEqual(['u2']);
364395
expect(chips(read)).toEqual(['👍 1']);
365396
expect(toast.error).not.toHaveBeenCalled();
@@ -452,7 +483,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
452483
expect(server.reactions.map((r) => r.user_id)).toEqual(['u2']);
453484
});
454485

455-
it('a take-back clicked before the create answers deletes the row that create made', async () => {
486+
it('a take-back clicked before the create answers removes the row that create made', async () => {
456487
const server = makeServer();
457488
const u1 = server.as('u1');
458489
let answer!: () => void;
@@ -471,11 +502,12 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
471502
expect(chips(view)).toEqual([]);
472503
await act(async () => {});
473504
// The take-back waits for the row it takes back.
474-
expect(u1.delete).not.toHaveBeenCalled();
505+
expect(u1.unreact).not.toHaveBeenCalled();
475506

476507
await act(async () => answer());
477-
await waitFor(() => expect(u1.delete).toHaveBeenCalledTimes(1));
478-
expect(u1.delete.mock.calls[0]).toEqual([REACTION, 'r1']);
508+
await waitFor(() => expect(u1.unreact).toHaveBeenCalledTimes(1));
509+
expect(unreacted(u1)).toEqual(['r1']);
510+
expect(u1.delete).not.toHaveBeenCalled();
479511
expect(server.reactions).toEqual([]);
480512
expect(chips(view)).toEqual([]);
481513
expect(toast.error).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)