From 7485545e8765d17fb5026ed50eb5501214af1a03 Mon Sep 17 00:00:00 2001 From: JNO Date: Mon, 2 Nov 2026 08:48:53 +0000 Subject: [PATCH 1/2] fix(meetings): select a person's older tasks at once and add them in one step The earlier-task chooser in meeting preparation added the selected tasks one request at a time. Every request refreshed the agenda behind the dialog, so adding ten tasks took long and the page jumped once per task. - Each person group (and "No assignee") has a three-state checkbox that selects or clears the group's tasks visible under the current search. - POST /api/meetings/[id]/tasks accepts `taskIds` (up to 500, deduplicated) next to the single `taskId`. All ids are checked and added in one transaction under the agenda lock: one refused id refuses the batch and the error names the refused ids. Tasks already active in the agenda are skipped, a removed task is restored, and positions follow the selection order after the current last task. - The dialog sends one request with one optimistic update and closes; on failure it keeps the selection and shows one error. - The listing and the add share one eligibility condition, matching the item rule of getVisibleTask; personal tasks no longer appear in the chooser (they could never be added). --- messages/cs-CZ.json | 1 + messages/en.json | 1 + src/app/api/meetings/[id]/tasks/route.ts | 10 +- .../meetings/older-tasks-dialog.tsx | 299 +++++++------ src/lib/meetings/model.ts | 9 + src/lib/meetings/service.ts | 123 ++++-- .../older-tasks-batch.integration.test.ts | 418 ++++++++++++++++++ .../meetings/older-tasks-dialog.test.tsx | 180 ++++++-- 8 files changed, 835 insertions(+), 206 deletions(-) create mode 100644 src/tests/meetings/older-tasks-batch.integration.test.ts diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 16ce0d46..0df886be 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1647,6 +1647,7 @@ "sdkVRt": "Načítám úkoly…", "C9zLOs": "Žádné starší nedokončené úkoly", "MY5T76": "Nic neodpovídá hledání.", + "IlpEFO": "Vybrat všechny úkoly: {name}", "dUnu9I": "Vybráno: {count, number}", "b1g2Cx": "Nic nevybráno", "b8dDI6": "Přidat {count, plural, one {# úkol} few {# úkoly} other {# úkolů}}", diff --git a/messages/en.json b/messages/en.json index d18f435a..10e44ee5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1647,6 +1647,7 @@ "sdkVRt": "Loading tasks…", "C9zLOs": "No older unfinished tasks", "MY5T76": "Nothing matches the search.", + "IlpEFO": "Select all tasks: {name}", "dUnu9I": "Selected: {count, number}", "b1g2Cx": "Nothing selected", "b8dDI6": "Add {count, plural, one {# task} other {# tasks}}", diff --git a/src/app/api/meetings/[id]/tasks/route.ts b/src/app/api/meetings/[id]/tasks/route.ts index b932e45f..6535a4ea 100644 --- a/src/app/api/meetings/[id]/tasks/route.ts +++ b/src/app/api/meetings/[id]/tasks/route.ts @@ -1,9 +1,7 @@ -import { z } from "zod"; +import { addAgendaTasksInput } from "@/lib/meetings/model"; import { meetingRoute } from "@/lib/meetings/route"; -import { addAgendaTask } from "@/lib/meetings/service"; +import { addAgendaTasks } from "@/lib/meetings/service"; export const POST = meetingRoute(async ({ userId, params, request }) => { - const input = z - .object({ taskId: z.string().min(1).max(255) }) - .parse(await request.json()); - await addAgendaTask(userId, params.id, input.taskId); + const input = addAgendaTasksInput.parse(await request.json()); + await addAgendaTasks(userId, params.id, input.taskIds ?? [input.taskId]); }); diff --git a/src/components/meetings/older-tasks-dialog.tsx b/src/components/meetings/older-tasks-dialog.tsx index 96177dcf..874431e3 100644 --- a/src/components/meetings/older-tasks-dialog.tsx +++ b/src/components/meetings/older-tasks-dialog.tsx @@ -23,6 +23,114 @@ function searchText(value: string) { return value.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase(); } +function OlderTaskGroup({ + group, + selected, + adding, + onToggleTask, + onToggleGroup, + ids, +}: { + group: ReturnType>[number]; + selected: ReadonlyMap; + adding: boolean; + onToggleTask: (task: OlderTask, checked: boolean) => void; + onToggleGroup: (tasks: OlderTask[], checked: boolean) => void; + ids: string; +}) { + const i18n = useExtracted(); + const format = useFormatter(); + const checked = group.tasks.every((task) => selected.has(task.id)); + const mixed = !checked && group.tasks.some((task) => selected.has(task.id)); + return ( +
+
+ { + if (node) node.indeterminate = mixed; + }} + disabled={adding} + onChange={(event) => + onToggleGroup(group.tasks, event.target.checked) + } + /> +

+ {group.name ?? i18n("No assignee")} + {!group.invited && group.name && ( + + {i18n("Not invited")} + + )} +

+
+
    + {group.tasks.map((task, taskIndex) => { + const source = task.evidence ?? task.recording; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + /** Picks unfinished tasks from the agenda's folders and adds the selected ones to the agenda. */ export function OlderTasksDialog({ detail, @@ -36,7 +144,6 @@ export function OlderTasksDialog({ mutate: AgendaController["mutate"]; }) { const i18n = useExtracted(); - const format = useFormatter(); const [scope, setScope] = useState<"invited" | "all">("invited"); const [older, setOlder] = useState(null); const [error, setError] = useState(null); @@ -82,42 +189,61 @@ export function OlderTasksDialog({ const selection = [...selected.values()].filter( (task) => !inAgenda.has(task.id), ); + const toggleTask = (task: OlderTask, checked: boolean) => { + setSelected((current) => { + const next = new Map(current); + if (checked) next.set(task.id, task); + else next.delete(task.id); + return next; + }); + }; + const toggleGroup = (tasks: OlderTask[], checked: boolean) => { + setSelected((current) => { + const next = new Map(current); + for (const task of tasks) { + if (checked) next.set(task.id, task); + else next.delete(task.id); + } + return next; + }); + }; const add = async () => { if (adding || !selection.length) return; setAdding(true); - let failed = false; - for (const task of selection) { - // mutate reports its own failure; stop there so an outage shows one error and the rest stay selected. - const saved = await mutate( - "/tasks", - "POST", - { taskId: task.id }, - (value) => ({ + const saved = await mutate( + "/tasks", + "POST", + { taskIds: selection.map((task) => task.id) }, + (value) => { + const ids = new Set(selection.map((task) => task.id)); + const lastPosition = Math.max( + -1, + ...value.tasks.map((task) => task.position), + ); + return { ...value, tasks: [ - ...value.tasks.filter((entry) => entry.id !== task.id), - { - ...task, - removedAt: null, - agendaNote: null, - agendaOrigin: "older", - position: 100000, - }, + ...value.tasks.filter((entry) => !ids.has(entry.id)), + ...selection.map((task, index) => { + const previous = value.tasks.find( + (entry) => entry.id === task.id, + ); + return { + ...task, + removedAt: null, + agendaNote: previous?.agendaNote ?? null, + agendaOrigin: + previous?.agendaOrigin ?? + ("older" as const), + position: lastPosition + index + 1, + }; + }), ], - }), - ).catch(() => false); - if (!saved) { - failed = true; - break; - } - setSelected((current) => { - const next = new Map(current); - next.delete(task.id); - return next; - }); - } + }; + }, + ).catch(() => false); setAdding(false); - if (!failed) { + if (saved) { setSelected(new Map()); onOpenChange(false); } @@ -194,112 +320,15 @@ export function OlderTasksDialog({

) : ( groups.map((group, groupIndex) => ( -
-

- {group.name ?? i18n("No assignee")} - {!group.invited && group.name && ( - - {i18n("Not invited")} - - )} -

-
    - {group.tasks.map((task, taskIndex) => { - const source = - task.evidence ?? task.recording; - return ( -
  • - -
  • - ); - })} -
-
+ group={group} + selected={selected} + adding={adding} + onToggleTask={toggleTask} + onToggleGroup={toggleGroup} + ids={`${rowId}-${groupIndex}`} + /> )) )} diff --git a/src/lib/meetings/model.ts b/src/lib/meetings/model.ts index 4106e1b3..1c0cb133 100644 --- a/src/lib/meetings/model.ts +++ b/src/lib/meetings/model.ts @@ -2,6 +2,15 @@ import { z } from "zod"; import { AppError, ErrorCode } from "@/lib/errors"; const id = z.string().min(1).max(255); +export const agendaTaskIdsInput = z + .array(id) + .min(1) + .max(500) + .transform((ids) => [...new Set(ids)]); +export const addAgendaTasksInput = z.union([ + z.object({ taskIds: agendaTaskIdsInput, taskId: z.never().optional() }), + z.object({ taskId: id, taskIds: z.never().optional() }), +]); const line = z .string() .trim() diff --git a/src/lib/meetings/service.ts b/src/lib/meetings/service.ts index fa8de4c7..453f2a6f 100644 --- a/src/lib/meetings/service.ts +++ b/src/lib/meetings/service.ts @@ -69,6 +69,7 @@ import { } from "@/lib/meetings/calendar-snapshot"; import { meetingInvitees } from "@/lib/meetings/meeting-roster"; import { + agendaTaskIdsInput, createMeetingInput, createPointInput, groupAgendaTasks, @@ -78,7 +79,7 @@ import { } from "@/lib/meetings/model"; import { agendaSuggestionsAvailability } from "@/lib/meetings/suggestions-provider"; import { taskViewerById } from "@/lib/tasks/access"; -import { getVisibleTask, getVisibleTasks } from "@/lib/tasks/tasks"; +import { getVisibleTasks } from "@/lib/tasks/tasks"; type Agenda = typeof meetingAgendas.$inferSelect; type Access = Awaited>; @@ -1398,33 +1399,83 @@ export async function addAgendaTask( id: string, taskId: string, ) { + return addAgendaTasks(userId, id, [taskId]); +} + +/** Add older tasks atomically in selection order under the agenda lock. */ +export async function addAgendaTasks( + userId: string, + id: string, + rawTaskIds: readonly string[], +) { + const taskIds = agendaTaskIdsInput.parse(rawTaskIds); const viewer = { ...(await taskViewerById(userId)), includeReceived: true }; - const task = await getVisibleTask(viewer, taskId); - if ( - !task || - task.own || - !(await olderTasks(userId, id, "all")).some( - (candidate) => candidate?.id === taskId, - ) - ) - throw new AppError( - ErrorCode.NOT_FOUND, - "Open task in this folder not found", - 404, - ); await editMeeting(userId, id, "edit", async (tx, { agenda }) => { + const folders = agenda.meetingItemId + ? await meetingFolders(tx, agenda.meetingItemId, agenda.userId) + : []; + const candidates = folders.length + ? await tx + .select({ id: recordingTasks.id }) + .from(recordingTasks) + .leftJoin( + chatterItems, + eq(chatterItems.id, recordingTasks.itemId), + ) + .where( + and( + inArray(recordingTasks.id, taskIds), + olderTaskCondition( + viewer, + folders.map((folder) => folder.id), + ), + ), + ) + : []; + const links = await tx + .select() + .from(agendaTasks) + .where( + and( + eq(agendaTasks.agendaId, agenda.id), + eq(agendaTasks.userId, agenda.userId), + ), + ); + const eligible = new Set(candidates.map((task) => task.id)); + const current = new Set( + links.filter((link) => !link.removedAt).map((link) => link.taskId), + ); + const refusedTaskIds = taskIds.filter( + (taskId) => !eligible.has(taskId), + ); + if (refusedTaskIds.length) + throw new AppError( + ErrorCode.NOT_FOUND, + "Open task in this folder not found", + 404, + { refusedTaskIds }, + ); + const addedTaskIds = taskIds.filter((taskId) => !current.has(taskId)); + if (!addedTaskIds.length) return false; + const lastPosition = Math.max( + -1, + ...links.map((link) => link.position), + ); await tx .insert(agendaTasks) - .values({ - agendaId: agenda.id, - userId: agenda.userId, - taskId, - origin: "older", - position: 100000, - }) + .values( + addedTaskIds.map((taskId, index) => ({ + agendaId: agenda.id, + userId: agenda.userId, + taskId, + origin: "older" as const, + position: lastPosition + index + 1, + })), + ) .onConflictDoUpdate({ target: [agendaTasks.agendaId, agendaTasks.taskId], - set: { removedAt: null }, + set: { removedAt: null, position: sql`excluded.position` }, + setWhere: eq(agendaTasks.userId, agenda.userId), }); }); } @@ -1550,6 +1601,23 @@ export async function decideSuggestion( }); } +function olderTaskCondition(viewer: TaskViewer, folderIds: string[]) { + return and( + eq(recordingTasks.status, "open"), + ne(recordingTasks.source, "personal"), + isNull(chatterItems.deletedAt), + or( + isNull(chatterItems.id), + inArray(chatterItems.kind, ["audio", "mail"]), + ), + or( + sql`exists (select 1 from ${recordingFolderAssignments} where ${recordingFolderAssignments.itemId} = ${recordingTasks.itemId} and ${inArray(recordingFolderAssignments.folderId, folderIds)})`, + sql`exists (select 1 from ${taskMentions} inner join ${recordingFolderAssignments} on ${recordingFolderAssignments.itemId} = ${taskMentions.itemId} where ${taskMentions.taskId} = ${recordingTasks.id} and ${taskMentions.matchDecision} in ('home', 'accepted', 'kept_new') and ${inArray(recordingFolderAssignments.folderId, folderIds)})`, + ), + taskVisible(viewer, ANY_TASK_ITEM, true), + ); +} + /** Open tasks in the meeting's folder, optionally limited to its invitees; never pre-added. */ export async function olderTasks( userId: string, @@ -1565,16 +1633,7 @@ export async function olderTasks( .select({ id: recordingTasks.id }) .from(recordingTasks) .leftJoin(chatterItems, eq(chatterItems.id, recordingTasks.itemId)) - .where( - and( - eq(recordingTasks.status, "open"), - or( - sql`exists (select 1 from ${recordingFolderAssignments} where ${recordingFolderAssignments.itemId} = ${recordingTasks.itemId} and ${inArray(recordingFolderAssignments.folderId, folderIds)})`, - sql`exists (select 1 from ${taskMentions} inner join ${recordingFolderAssignments} on ${recordingFolderAssignments.itemId} = ${taskMentions.itemId} where ${taskMentions.taskId} = ${recordingTasks.id} and ${taskMentions.matchDecision} in ('home', 'accepted', 'kept_new') and ${inArray(recordingFolderAssignments.folderId, folderIds)})`, - ), - taskVisible(viewer, ANY_TASK_ITEM, true), - ), - ) + .where(olderTaskCondition(viewer, folderIds)) .limit(500); const current = new Set( detail.tasks.filter((task) => !task.removedAt).map((task) => task.id), diff --git a/src/tests/meetings/older-tasks-batch.integration.test.ts b/src/tests/meetings/older-tasks-batch.integration.test.ts new file mode 100644 index 00000000..2cadbead --- /dev/null +++ b/src/tests/meetings/older-tasks-batch.integration.test.ts @@ -0,0 +1,418 @@ +import { asc, eq } from "drizzle-orm"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + agendaTasks, + chatterItems, + meetingAgendas, + recordingFolderAssignments, + recordingFolders, + recordingTasks, + taskMentions, + users, +} from "@/db/schema"; +import { decryptText, encryptText } from "@/lib/encryption/fields"; +import { lookupHash } from "@/lib/knowledge/lookup-hash"; +import { + createMigratedTestDatabase, + getTestDatabaseUrl, + type TestPostgresDatabase, +} from "@/tests/integration/postgres"; + +const holder = vi.hoisted(() => ({ + database: null as Record | null, + caller: "owner", +})); +vi.mock("@/db", () => ({ + db: new Proxy( + {}, + { + get(_target, key) { + const value = holder.database?.[key]; + return typeof value === "function" + ? value.bind(holder.database) + : value; + }, + }, + ), +})); +vi.mock("@/lib/env", () => ({ + env: { + ENCRYPTION_KEY: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + AUTHZ_MODE: "local", + IS_HOSTED: false, + BETTER_AUTH_SECRET: "meeting-batch-test", + APP_URL: "https://example.com", + }, +})); +vi.mock("@/lib/org/config", () => ({ + getOrgUserId: async () => null, + isOrgScopeVisible: () => true, + assertOrgScopeWritable: () => {}, +})); +vi.mock("@/lib/authz/curator", () => ({ + actsForOrganization: async () => false, + userCuratesOrganizationNow: async () => false, +})); +vi.mock("@/lib/auth-server", () => ({ + requireApiSession: async () => ({ user: { id: holder.caller } }), +})); +vi.mock("@/lib/posthog-server", () => ({ captureServerException: vi.fn() })); + +import { POST } from "@/app/api/meetings/[id]/tasks/route"; +import { addAgendaTask, addAgendaTasks } from "@/lib/meetings/service"; + +const url = getTestDatabaseUrl(); +describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { + let database: TestPostgresDatabase; + beforeAll(async () => { + database = await createMigratedTestDatabase( + url ?? "", + "older_task_batch", + ); + holder.database = database.db as unknown as Record< + PropertyKey, + unknown + >; + }, 120000); + afterAll(async () => { + holder.database = null; + await database?.dispose(); + }, 60000); + beforeEach(async () => { + holder.caller = "owner"; + const db = database.db; + await db.delete(users); + await db.insert(users).values( + ["owner", "stranger"].map((id) => ({ + id, + email: `${id}@example.com`, + })), + ); + await db.insert(recordingFolders).values( + ["first", "second", "outside"].map((id) => ({ + id, + userId: "owner", + name: encryptText(id), + nameHash: lookupHash(id), + })), + ); + await db.insert(chatterItems).values( + [ + { id: "meeting", userId: "owner", kind: "meeting" as const }, + { id: "source", userId: "owner", kind: "audio" as const }, + { id: "other-source", userId: "owner", kind: "audio" as const }, + { + id: "private-source", + userId: "stranger", + kind: "audio" as const, + }, + { + id: "outside-source", + userId: "owner", + kind: "audio" as const, + }, + ].map((row) => ({ + ...row, + title: encryptText("Planning"), + occurredAt: new Date(), + })), + ); + await db.insert(meetingAgendas).values({ + id: "agenda", + userId: "owner", + meetingItemId: "meeting", + }); + await db.insert(recordingFolderAssignments).values([ + { itemId: "meeting", folderId: "first", userId: "owner" }, + { itemId: "meeting", folderId: "second", userId: "owner" }, + { itemId: "source", folderId: "first", userId: "owner" }, + { itemId: "other-source", folderId: "second", userId: "owner" }, + { itemId: "outside-source", folderId: "outside", userId: "owner" }, + ]); + await db.insert(recordingTasks).values( + [ + { id: "existing", itemId: "source", userId: "owner" }, + { id: "one", itemId: "source", userId: "owner" }, + { id: "two", itemId: "other-source", userId: "owner" }, + { id: "three", itemId: "source", userId: "owner" }, + { + id: "invisible", + itemId: "private-source", + userId: "stranger", + }, + { id: "outside", itemId: "outside-source", userId: "owner" }, + ].map((row) => ({ + ...row, + status: "open" as const, + source: "manual" as const, + text: encryptText("Follow up"), + })), + ); + await db.insert(agendaTasks).values({ + agendaId: "agenda", + userId: "owner", + taskId: "existing", + origin: "last_meeting", + position: 7, + }); + }); + const post = (body: unknown) => + POST( + new Request("https://example.com/api/meetings/agenda/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: "agenda" }) }, + ); + const links = () => + database.db + .select() + .from(agendaTasks) + .orderBy(asc(agendaTasks.position)); + async function version() { + const [row] = await database.db + .select() + .from(meetingAgendas) + .where(eq(meetingAgendas.id, "agenda")); + return row.version; + } + it("adds tasks from every agenda folder in selection order and bumps once", async () => { + expect((await post({ taskIds: ["two", "one", "three"] })).status).toBe( + 200, + ); + expect( + (await links()).map((row) => [row.taskId, row.position]), + ).toEqual([ + ["existing", 7], + ["two", 8], + ["one", 9], + ["three", 10], + ]); + expect(await version()).toBe(1); + }); + it("refuses all invisible or missing ids without adding valid tasks or bumping the agenda", async () => { + await database.db.insert(taskMentions).values({ + taskId: "invisible", + itemId: "source", + userId: "owner", + view: "private", + statementAt: new Date(), + matchDecision: "accepted", + }); + const response = await post({ + taskIds: ["one", "invisible", "two", "missing"], + }); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + details: { refusedTaskIds: ["invisible", "missing"] }, + }); + expect((await links()).map((row) => row.taskId)).toEqual(["existing"]); + expect(await version()).toBe(0); + }); + it("restores removed membership at the end and preserves its note and origin", async () => { + await database.db.insert(agendaTasks).values({ + agendaId: "agenda", + userId: "owner", + taskId: "one", + origin: "last_meeting", + position: 2, + removedAt: new Date(), + note: encryptText("Review next time"), + }); + expect((await post({ taskIds: ["two", "one"] })).status).toBe(200); + const rows = await links(); + expect(rows.map((row) => [row.taskId, row.position])).toEqual([ + ["existing", 7], + ["two", 8], + ["one", 9], + ]); + expect(rows[2].removedAt).toBeNull(); + expect(rows[2].origin).toBe("last_meeting"); + expect(decryptText(rows[2].note ?? "")).toBe("Review next time"); + }); + it("deduplicates ids while preserving their first selection order", async () => { + expect( + (await post({ taskIds: ["two", "one", "two", "one"] })).status, + ).toBe(200); + expect( + (await links()).map((row) => [row.taskId, row.position]), + ).toEqual([ + ["existing", 7], + ["two", 8], + ["one", 9], + ]); + expect(await version()).toBe(1); + }); + it("skips active members while adding the remaining tasks in selection order", async () => { + await database.db + .update(agendaTasks) + .set({ note: encryptText("Keep this note") }) + .where(eq(agendaTasks.taskId, "existing")); + const [existing] = await links(); + expect( + (await post({ taskIds: ["two", "existing", "one"] })).status, + ).toBe(200); + const rows = await links(); + expect(rows[0]).toEqual(existing); + expect(rows.map((row) => [row.taskId, row.position])).toEqual([ + ["existing", 7], + ["two", 8], + ["one", 9], + ]); + expect(await version()).toBe(1); + }); + it("succeeds without changing links or the agenda when all ids are active", async () => { + await database.db + .update(agendaTasks) + .set({ note: encryptText("Keep this note") }) + .where(eq(agendaTasks.taskId, "existing")); + const beforeLinks = await links(); + const beforeAgendas = await database.db.select().from(meetingAgendas); + expect((await post({ taskIds: ["existing"] })).status).toBe(200); + await addAgendaTask("owner", "agenda", "existing"); + expect(await links()).toEqual(beforeLinks); + expect(await database.db.select().from(meetingAgendas)).toEqual( + beforeAgendas, + ); + }); + it("retains the single-task route and MCP service entry point", async () => { + expect((await post({ taskId: "one" })).status).toBe(200); + await addAgendaTask("owner", "agenda", "two"); + expect((await links()).map((row) => row.taskId)).toEqual([ + "existing", + "one", + "two", + ]); + }); + it("rejects tasks outside all agenda folders and rolls back the batch", async () => { + const response = await post({ taskIds: ["one", "outside"] }); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + details: { refusedTaskIds: ["outside"] }, + }); + expect(await links()).toHaveLength(1); + }); + it("accepts folder membership through an accepted mention", async () => { + await database.db.insert(taskMentions).values({ + taskId: "outside", + itemId: "source", + userId: "owner", + view: "private", + statementAt: new Date(), + matchDecision: "accepted", + }); + expect((await post({ taskIds: ["outside"] })).status).toBe(200); + expect((await links()).map((row) => row.taskId)).toEqual([ + "existing", + "outside", + ]); + }); + it.each([ + "done", + "dropped", + "proposed", + ] as const)("refuses %s tasks atomically", async (status) => { + await database.db + .update(recordingTasks) + .set({ status }) + .where(eq(recordingTasks.id, "two")); + expect((await post({ taskIds: ["one", "two"] })).status).toBe(404); + expect(await links()).toHaveLength(1); + }); + it("refuses deleted sources and personal tasks while allowing current members", async () => { + await database.db + .update(chatterItems) + .set({ deletedAt: new Date() }) + .where(eq(chatterItems.id, "other-source")); + await database.db.insert(recordingTasks).values({ + id: "personal", + userId: "owner", + folderId: "first", + source: "personal", + status: "open", + text: encryptText("Own task"), + }); + const response = await post({ + taskIds: ["one", "two", "personal", "existing"], + }); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + details: { refusedTaskIds: ["two", "personal"] }, + }); + expect(await links()).toHaveLength(1); + expect(await version()).toBe(0); + }); + it("still refuses an active member that is no longer eligible", async () => { + await database.db + .update(recordingTasks) + .set({ status: "done" }) + .where(eq(recordingTasks.id, "existing")); + const response = await post({ taskIds: ["one", "existing"] }); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + details: { refusedTaskIds: ["existing"] }, + }); + expect(await links()).toHaveLength(1); + expect(await version()).toBe(0); + }); + it("requires agenda edit access and keeps absorbed agendas read-only", async () => { + holder.caller = "stranger"; + expect((await post({ taskIds: ["one"] })).status).toBe(404); + holder.caller = "owner"; + await database.db + .update(meetingAgendas) + .set({ recordingItemId: "source", absorbedAt: new Date() }) + .where(eq(meetingAgendas.id, "agenda")); + expect((await post({ taskIds: ["one"] })).status).toBe(409); + expect(await links()).toHaveLength(1); + }); + it("serializes concurrent batches so each appends contiguous positions", async () => { + await Promise.all([ + addAgendaTasks("owner", "agenda", ["two", "one"]), + addAgendaTasks("owner", "agenda", ["three"]), + ]); + const rows = await links(); + expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]); + const ids = rows.map((row) => row.taskId); + expect(ids.indexOf("one")).toBe(ids.indexOf("two") + 1); + expect(await version()).toBe(2); + }); + it("accepts overlapping concurrent batches without moving existing links", async () => { + await Promise.all([ + addAgendaTasks("owner", "agenda", ["one", "two"]), + addAgendaTasks("owner", "agenda", ["two", "three"]), + ]); + const rows = await links(); + expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]); + expect(rows.map((row) => row.taskId).sort()).toEqual([ + "existing", + "one", + "three", + "two", + ]); + expect(await version()).toBe(2); + }); + it.each([ + {}, + { taskIds: [] }, + { taskIds: [""] }, + { taskIds: [42] }, + { taskIds: ["x".repeat(256)] }, + { taskIds: Array.from({ length: 501 }, () => "one") }, + { taskIds: ["one"], taskId: "two" }, + ])("rejects malformed or oversized input: %j", async (body) => { + expect((await post(body)).status).toBe(400); + expect(await links()).toHaveLength(1); + expect(await version()).toBe(0); + }); +}); diff --git a/src/tests/meetings/older-tasks-dialog.test.tsx b/src/tests/meetings/older-tasks-dialog.test.tsx index a47284ee..24e5ba7e 100644 --- a/src/tests/meetings/older-tasks-dialog.test.tsx +++ b/src/tests/meetings/older-tasks-dialog.test.tsx @@ -18,10 +18,14 @@ import { useAgenda } from "@/components/meetings/use-agenda"; import { translatedSourceMessage } from "@/lib/i18n/messages"; import { at, detail, task } from "./ui-fixtures"; -const { toastError, onOpenChange } = vi.hoisted(() => ({ - toastError: vi.fn(), - onOpenChange: vi.fn(), -})); +const { toastError, onOpenChange, mutateCall, optimisticUpdate } = vi.hoisted( + () => ({ + toastError: vi.fn(), + mutateCall: vi.fn(), + optimisticUpdate: vi.fn(), + onOpenChange: vi.fn(), + }), +); vi.mock("sonner", () => ({ toast: { error: toastError } })); vi.mock("next-intl", () => ({ useExtracted: () => (text: string, values?: TranslationValues) => @@ -61,7 +65,14 @@ function Harness() { onOpenChange(value); setOpen(value); }} - mutate={agenda.mutate} + mutate={(path, method, body, optimistic) => { + mutateCall(path, method, body); + return agenda.mutate(path, method, body, (value) => { + const next = optimistic(value); + optimisticUpdate(next); + return next; + }); + }} /> )} @@ -94,25 +105,33 @@ beforeEach(() => { if (url.includes("/older-tasks")) return Response.json({ tasks: older }); if (init?.method === "POST") { - const { taskId } = JSON.parse(String(init.body)) as { - taskId: string; + const { taskIds } = JSON.parse(String(init.body)) as { + taskIds: string[]; }; - const entry = older.find( - (candidate) => candidate.id === taskId, + const lastPosition = Math.max( + -1, + ...server.tasks.map((entry) => entry.position), ); - if (!entry) throw new Error("Unknown test task"); server = { ...server, version: server.version + 1, tasks: [ - ...server.tasks, - { - ...entry, - agendaOrigin: "older", - agendaNote: null, - removedAt: null, - position: 100000, - }, + ...server.tasks.filter( + (entry) => !taskIds.includes(entry.id), + ), + ...taskIds.map((id, index) => { + const entry = older.find( + (candidate) => candidate.id === id, + ); + if (!entry) throw new Error("Unknown test task"); + return { + ...entry, + agendaOrigin: "older" as const, + agendaNote: null, + removedAt: null, + position: lastPosition + index + 1, + }; + }), ], }; return Response.json({ success: true }); @@ -161,7 +180,7 @@ it("searches task text without case or diacritics and preserves selections while fireEvent.click(screen.getByRole("checkbox", { name: "Review dates" })); const search = screen.getByRole("searchbox", { name: "Search tasks" }); fireEvent.change(search, { target: { value: "ZLUTOUCKY" } }); - expect(screen.getAllByRole("checkbox")).toHaveLength(1); + expect(screen.getAllByRole("checkbox")).toHaveLength(2); expect( screen.getByRole("checkbox", { name: "Prepare Žluťoučký report" }), ).toBeTruthy(); @@ -202,7 +221,7 @@ it("switches scope through the segmented control and refetches", async () => { ); }); -it("selects real checkboxes, updates the footer and adds each task in selection order", async () => { +it("adds the selection in one request, one optimistic update and one refresh", async () => { await mount(); expect(screen.getByText("Nothing selected")).toBeTruthy(); expect(screen.getByRole("button", { name: "Add tasks" })).toHaveProperty( @@ -230,20 +249,43 @@ it("selects real checkboxes, updates the footer and adds each task in selection expect( posts.map(([url, init]) => [url, JSON.parse(String(init.body))]), ).toEqual([ - ["/api/meetings/agenda/tasks", { taskId: "Review dates" }], - ["/api/meetings/agenda/tasks", { taskId: "Prepare Žluťoučký report" }], + [ + "/api/meetings/agenda/tasks", + { taskIds: ["Review dates", "Prepare Žluťoučký report"] }, + ], ]); + expect(mutateCall).toHaveBeenCalledExactlyOnceWith("/tasks", "POST", { + taskIds: ["Review dates", "Prepare Žluťoučký report"], + }); + expect(optimisticUpdate).toHaveBeenCalledTimes(1); + expect( + optimisticUpdate.mock.calls[0][0].tasks.map( + (entry: { id: string; position: number }) => [ + entry.id, + entry.position, + ], + ), + ).toEqual([ + ["already-added", 0], + ["Review dates", 1], + ["Prepare Žluťoučký report", 2], + ]); + expect( + fetchMock.mock.calls.filter(([url]) => + url.startsWith("/api/meetings/agenda?since="), + ), + ).toHaveLength(1); expect(screen.queryByRole("dialog")).toBeNull(); expect(onOpenChange).toHaveBeenCalledWith(false); expect(screen.getByTestId("agenda-tasks").textContent).toContain( - "Review dates:older:100000", + "Review dates:older:1", ); fireEvent.click(screen.getByRole("button", { name: "Open picker" })); await screen.findByRole("checkbox", { name: "Check agenda" }); expect(screen.getByText("Nothing selected")).toBeTruthy(); }); -it("stops the batch at a failure, keeps the rest selected and the dialog open, and retries them", async () => { +it("keeps the entire selection and dialog open on failure, rolls back and retries once", async () => { const base = fetchMock.getMockImplementation(); let fail = true; fetchMock.mockImplementation((url: string, init?: RequestInit) => { @@ -279,29 +321,40 @@ it("stops the batch at a failure, keeps the rest selected and the dialog open, a screen.getByRole("checkbox", { name: "Check agenda" }), ).toHaveProperty("checked", true); expect( - screen.queryByRole("checkbox", { name: "Prepare Žluťoučký report" }), - ).toBeNull(); - expect(screen.getByText("Selected: 2")).toBeTruthy(); + screen.getByRole("checkbox", { name: "Prepare Žluťoučký report" }), + ).toHaveProperty("checked", true); + expect(screen.getByText("Selected: 3")).toBeTruthy(); + expect(screen.getByTestId("agenda-tasks").textContent).toBe( + "already-added:last_meeting:0", + ); expect(toastError).toHaveBeenCalledExactlyOnceWith( "Could not save the agenda", ); expect( fetchMock.mock.calls.filter(([, init]) => init.method === "POST"), - ).toHaveLength(2); + ).toHaveLength(1); fail = false; fetchMock.mockClear(); await act(async () => - fireEvent.click(screen.getByRole("button", { name: "Add 2 tasks" })), + fireEvent.click(screen.getByRole("button", { name: "Add 3 tasks" })), ); expect( fetchMock.mock.calls .filter(([, init]) => init.method === "POST") .map(([, init]) => JSON.parse(String(init.body))), - ).toEqual([{ taskId: "Review dates" }, { taskId: "Check agenda" }]); + ).toEqual([ + { + taskIds: [ + "Prepare Žluťoučký report", + "Review dates", + "Check agenda", + ], + }, + ]); expect(screen.queryByRole("dialog")).toBeNull(); }); -it("optimistically adds a task before its POST completes", async () => { +it("optimistically adds the whole batch before its POST completes", async () => { const base = fetchMock.getMockImplementation(); let finish = () => {}; fetchMock.mockImplementation((url: string, init?: RequestInit) => @@ -315,12 +368,18 @@ it("optimistically adds a task before its POST completes", async () => { ); await mount(); fireEvent.click(screen.getByRole("checkbox", { name: "Review dates" })); - fireEvent.click(screen.getByRole("button", { name: "Add 1 task" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Check agenda" })); + fireEvent.click(screen.getByRole("button", { name: "Add 2 tasks" })); await waitFor(() => expect(screen.getByTestId("agenda-tasks").textContent).toContain( - "Review dates:older:100000", + "Review dates:older:1", ), ); + expect(screen.getByTestId("agenda-tasks").textContent).toContain( + "Check agenda:older:2", + ); + expect(mutateCall).toHaveBeenCalledTimes(1); + expect(optimisticUpdate).toHaveBeenCalledTimes(1); await act(async () => finish()); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); @@ -417,3 +476,58 @@ it("traps keyboard focus inside the dialog", async () => { true, ); }); + +it("selects and clears visible group tasks with mixed state while preserving hidden selections", async () => { + older.push(task("Review budget", other)); + older.push(task("already-added", other)); + await mount(); + const group = () => + screen.getByRole("checkbox", { name: `Select all tasks: ${other}` }); + expect(group()).toHaveProperty("checked", false); + expect(group()).toHaveProperty("indeterminate", false); + fireEvent.click(screen.getByRole("checkbox", { name: "Review dates" })); + expect(group()).toHaveProperty("indeterminate", true); + expect(group().getAttribute("aria-checked")).toBe("mixed"); + fireEvent.click(group()); + expect(group()).toHaveProperty("checked", true); + expect(group()).toHaveProperty("indeterminate", false); + expect(screen.getByText("Selected: 2")).toBeTruthy(); + expect( + screen.getByRole("checkbox", { name: "Prepare Žluťoučký report" }), + ).toHaveProperty("checked", false); + expect( + screen.getByRole("checkbox", { name: "Check agenda" }), + ).toHaveProperty("checked", false); + const search = screen.getByRole("searchbox", { name: "Search tasks" }); + fireEvent.change(search, { target: { value: "dates" } }); + fireEvent.click(group()); + expect(screen.getByText("Selected: 1")).toBeTruthy(); + fireEvent.change(search, { target: { value: "" } }); + expect( + screen.getByRole("checkbox", { name: "Review budget" }), + ).toHaveProperty("checked", true); + expect(group()).toHaveProperty("indeterminate", true); + fireEvent.click(group()); + fireEvent.click(group()); + expect(screen.getByText("Nothing selected")).toBeTruthy(); + fireEvent.change(search, { target: { value: "dates" } }); + fireEvent.click(group()); + fireEvent.change(search, { target: { value: "" } }); + expect( + screen.getByRole("checkbox", { name: "Review budget" }), + ).toHaveProperty("checked", false); + expect( + screen.getByRole("checkbox", { name: "Review dates" }), + ).toHaveProperty("checked", true); +}); + +it("selects unassigned tasks as a group", async () => { + await mount(); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select all tasks: No assignee" }), + ); + expect( + screen.getByRole("checkbox", { name: "Check agenda" }), + ).toHaveProperty("checked", true); + expect(screen.getByText("Selected: 1")).toBeTruthy(); +}); From 873a35e3e0d3d71753f8c0c29a3d0b028bd11ece Mon Sep 17 00:00:00 2001 From: JNO Date: Mon, 2 Nov 2026 09:44:22 +0000 Subject: [PATCH 2/2] fix(meetings): keep added older tasks after the last-meeting snapshot The last-meeting snapshot re-inserts its tasks at positions 0..N-1 when the task source changes or the agenda is filed elsewhere. Added older tasks had moved to max+1, so they could tie or interleave with a larger re-snapshot. They start at OLDER_TASK_POSITION_BASE (100000) again, sequential in selection order, in the service and the optimistic update; agenda tasks order by position, then task id. --- .../meetings/older-tasks-dialog.tsx | 7 +- src/lib/meetings/positions.ts | 2 + src/lib/meetings/service.ts | 9 +- .../older-tasks-batch.integration.test.ts | 95 ++++++++++++++++--- .../meetings/older-tasks-dialog.test.tsx | 24 +++-- 5 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 src/lib/meetings/positions.ts diff --git a/src/components/meetings/older-tasks-dialog.tsx b/src/components/meetings/older-tasks-dialog.tsx index 874431e3..89e668b9 100644 --- a/src/components/meetings/older-tasks-dialog.tsx +++ b/src/components/meetings/older-tasks-dialog.tsx @@ -13,6 +13,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { OLDER_TASK_POSITION_BASE } from "@/lib/meetings/positions"; import { groupAgendaTasks } from "@/lib/meetings/task-groups"; import { cn } from "@/lib/utils"; import { meetingRequest, meetingUrl } from "./api"; @@ -220,6 +221,10 @@ export function OlderTasksDialog({ -1, ...value.tasks.map((task) => task.position), ); + const firstPosition = Math.max( + lastPosition + 1, + OLDER_TASK_POSITION_BASE, + ); return { ...value, tasks: [ @@ -235,7 +240,7 @@ export function OlderTasksDialog({ agendaOrigin: previous?.agendaOrigin ?? ("older" as const), - position: lastPosition + index + 1, + position: firstPosition + index, }; }), ], diff --git a/src/lib/meetings/positions.ts b/src/lib/meetings/positions.ts new file mode 100644 index 00000000..6eb4b5d5 --- /dev/null +++ b/src/lib/meetings/positions.ts @@ -0,0 +1,2 @@ +/** First agenda position of added older tasks, above every position the last-meeting snapshot assigns. */ +export const OLDER_TASK_POSITION_BASE = 100000; diff --git a/src/lib/meetings/service.ts b/src/lib/meetings/service.ts index 453f2a6f..dec1bfa2 100644 --- a/src/lib/meetings/service.ts +++ b/src/lib/meetings/service.ts @@ -77,6 +77,7 @@ import { patchPointInput, validatePointTree, } from "@/lib/meetings/model"; +import { OLDER_TASK_POSITION_BASE } from "@/lib/meetings/positions"; import { agendaSuggestionsAvailability } from "@/lib/meetings/suggestions-provider"; import { taskViewerById } from "@/lib/tasks/access"; import { getVisibleTasks } from "@/lib/tasks/tasks"; @@ -739,7 +740,7 @@ export async function getMeeting( eq(agendaTasks.userId, agenda.userId), ), ) - .orderBy(asc(agendaTasks.position)), + .orderBy(asc(agendaTasks.position), asc(agendaTasks.taskId)), db .select() .from(agendaSuggestions) @@ -1461,6 +1462,10 @@ export async function addAgendaTasks( -1, ...links.map((link) => link.position), ); + const firstPosition = Math.max( + lastPosition + 1, + OLDER_TASK_POSITION_BASE, + ); await tx .insert(agendaTasks) .values( @@ -1469,7 +1474,7 @@ export async function addAgendaTasks( userId: agenda.userId, taskId, origin: "older" as const, - position: lastPosition + index + 1, + position: firstPosition + index, })), ) .onConflictDoUpdate({ diff --git a/src/tests/meetings/older-tasks-batch.integration.test.ts b/src/tests/meetings/older-tasks-batch.integration.test.ts index 2cadbead..fc20aa6b 100644 --- a/src/tests/meetings/older-tasks-batch.integration.test.ts +++ b/src/tests/meetings/older-tasks-batch.integration.test.ts @@ -68,7 +68,12 @@ vi.mock("@/lib/auth-server", () => ({ vi.mock("@/lib/posthog-server", () => ({ captureServerException: vi.fn() })); import { POST } from "@/app/api/meetings/[id]/tasks/route"; -import { addAgendaTask, addAgendaTasks } from "@/lib/meetings/service"; +import { + addAgendaTask, + addAgendaTasks, + getMeeting, + patchMeeting, +} from "@/lib/meetings/service"; const url = getTestDatabaseUrl(); describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { @@ -194,12 +199,74 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { (await links()).map((row) => [row.taskId, row.position]), ).toEqual([ ["existing", 7], - ["two", 8], - ["one", 9], - ["three", 10], + ["two", 100000], + ["one", 100001], + ["three", 100002], ]); expect(await version()).toBe(1); }); + it("keeps a larger replacement snapshot before older tasks in selection order", async () => { + await addAgendaTasks("owner", "agenda", ["two", "one"]); + await database.db.insert(chatterItems).values({ + id: "replacement-source", + userId: "owner", + kind: "audio", + title: encryptText("Next planning session"), + occurredAt: new Date(), + }); + const snapshotIds = Array.from( + { length: 10 }, + (_, position) => `snapshot-${position}`, + ); + await database.db.insert(recordingTasks).values( + snapshotIds.map((id, position) => ({ + id, + itemId: "replacement-source", + userId: "owner", + status: "open" as const, + source: "manual" as const, + text: encryptText("Review milestone"), + position, + })), + ); + + const detail = await patchMeeting("owner", "agenda", { + taskSourceItemId: "replacement-source", + }); + if (detail.unchanged) throw new Error("Expected meeting detail"); + expect(detail.snapshotSourceItemId).toBe("replacement-source"); + expect(detail.tasks.map((task) => task.agendaOrigin)).toEqual([ + ...snapshotIds.map(() => "last_meeting"), + "older", + "older", + ]); + expect(detail.tasks.map((task) => task.id)).toEqual([ + ...snapshotIds, + "two", + "one", + ]); + expect(detail.tasks.slice(-2).map((task) => task.position)).toEqual([ + 100000, 100001, + ]); + }); + it("orders equal agenda positions by task id", async () => { + await database.db.insert(agendaTasks).values( + ["two", "one"].map((taskId) => ({ + agendaId: "agenda", + userId: "owner", + taskId, + origin: "older" as const, + position: 7, + })), + ); + const detail = await getMeeting("owner", "agenda"); + if (detail.unchanged) throw new Error("Expected meeting detail"); + expect(detail.tasks.map((task) => task.id)).toEqual([ + "existing", + "one", + "two", + ]); + }); it("refuses all invisible or missing ids without adding valid tasks or bumping the agenda", async () => { await database.db.insert(taskMentions).values({ taskId: "invisible", @@ -233,8 +300,8 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { const rows = await links(); expect(rows.map((row) => [row.taskId, row.position])).toEqual([ ["existing", 7], - ["two", 8], - ["one", 9], + ["two", 100000], + ["one", 100001], ]); expect(rows[2].removedAt).toBeNull(); expect(rows[2].origin).toBe("last_meeting"); @@ -248,8 +315,8 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { (await links()).map((row) => [row.taskId, row.position]), ).toEqual([ ["existing", 7], - ["two", 8], - ["one", 9], + ["two", 100000], + ["one", 100001], ]); expect(await version()).toBe(1); }); @@ -266,8 +333,8 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { expect(rows[0]).toEqual(existing); expect(rows.map((row) => [row.taskId, row.position])).toEqual([ ["existing", 7], - ["two", 8], - ["one", 9], + ["two", 100000], + ["one", 100001], ]); expect(await version()).toBe(1); }); @@ -382,7 +449,9 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { addAgendaTasks("owner", "agenda", ["three"]), ]); const rows = await links(); - expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]); + expect(rows.map((row) => row.position)).toEqual([ + 7, 100000, 100001, 100002, + ]); const ids = rows.map((row) => row.taskId); expect(ids.indexOf("one")).toBe(ids.indexOf("two") + 1); expect(await version()).toBe(2); @@ -393,7 +462,9 @@ describe.skipIf(!url)("older agenda task batches (PostgreSQL)", () => { addAgendaTasks("owner", "agenda", ["two", "three"]), ]); const rows = await links(); - expect(rows.map((row) => row.position)).toEqual([7, 8, 9, 10]); + expect(rows.map((row) => row.position)).toEqual([ + 7, 100000, 100001, 100002, + ]); expect(rows.map((row) => row.taskId).sort()).toEqual([ "existing", "one", diff --git a/src/tests/meetings/older-tasks-dialog.test.tsx b/src/tests/meetings/older-tasks-dialog.test.tsx index 24e5ba7e..39e9a49f 100644 --- a/src/tests/meetings/older-tasks-dialog.test.tsx +++ b/src/tests/meetings/older-tasks-dialog.test.tsx @@ -112,6 +112,7 @@ beforeEach(() => { -1, ...server.tasks.map((entry) => entry.position), ); + const firstPosition = Math.max(lastPosition + 1, 100000); server = { ...server, version: server.version + 1, @@ -129,7 +130,7 @@ beforeEach(() => { agendaOrigin: "older" as const, agendaNote: null, removedAt: null, - position: lastPosition + index + 1, + position: firstPosition + index, }; }), ], @@ -221,7 +222,14 @@ it("switches scope through the segmented control and refetches", async () => { ); }); -it("adds the selection in one request, one optimistic update and one refresh", async () => { +it.each([ + { lastPosition: 0, firstPosition: 100000 }, + { lastPosition: 100005, firstPosition: 100006 }, +])("adds the selection after $lastPosition in one request, one optimistic update and one refresh", async ({ + lastPosition, + firstPosition, +}) => { + server.tasks[0].position = lastPosition; await mount(); expect(screen.getByText("Nothing selected")).toBeTruthy(); expect(screen.getByRole("button", { name: "Add tasks" })).toHaveProperty( @@ -266,9 +274,9 @@ it("adds the selection in one request, one optimistic update and one refresh", a ], ), ).toEqual([ - ["already-added", 0], - ["Review dates", 1], - ["Prepare Žluťoučký report", 2], + ["already-added", lastPosition], + ["Review dates", firstPosition], + ["Prepare Žluťoučký report", firstPosition + 1], ]); expect( fetchMock.mock.calls.filter(([url]) => @@ -278,7 +286,7 @@ it("adds the selection in one request, one optimistic update and one refresh", a expect(screen.queryByRole("dialog")).toBeNull(); expect(onOpenChange).toHaveBeenCalledWith(false); expect(screen.getByTestId("agenda-tasks").textContent).toContain( - "Review dates:older:1", + `Review dates:older:${firstPosition}`, ); fireEvent.click(screen.getByRole("button", { name: "Open picker" })); await screen.findByRole("checkbox", { name: "Check agenda" }); @@ -372,11 +380,11 @@ it("optimistically adds the whole batch before its POST completes", async () => fireEvent.click(screen.getByRole("button", { name: "Add 2 tasks" })); await waitFor(() => expect(screen.getByTestId("agenda-tasks").textContent).toContain( - "Review dates:older:1", + "Review dates:older:100000", ), ); expect(screen.getByTestId("agenda-tasks").textContent).toContain( - "Check agenda:older:2", + "Check agenda:older:100001", ); expect(mutateCall).toHaveBeenCalledTimes(1); expect(optimisticUpdate).toHaveBeenCalledTimes(1);