#486 Executive Meetings: row-click quick actions popover (Move up / Move down / Postpone)
Clicking any meeting row on the schedule now opens a small popover with three
quick actions: Move up, Move down, and Postpone. Gated only on canMutate (NOT
editMode) per spec.
Backend
- New POST /executive-meetings/swap-times endpoint (artifacts/api-server/src/
routes/executive-meetings.ts). FOR UPDATE locks both rows by ascending id to
avoid deadlocks, optimistic-lock check via expectedUpdatedAt{A,B} (returns
409 stale_meeting + conflict.lastActor — same shape PostponeDialog
understands), guards different_dates and no_time_window, swaps only
(startTime, endTime), audits each row as `meeting_swap_times`, calls
renumberDayByStartTime so the # column matches the new chronological order,
and broadcasts emitExecutiveMeetingsDayChanged.
- New zod body schema ExecutiveMeetingsSwapTimesBody in lib/api-zod/src/manual.ts.
Frontend
- New shared lib/api-json.ts (ApiError + apiJson) extracted from upcoming-
meeting-alert.tsx so the page can reuse the same fetch/error contract.
- upcoming-meeting-alert.tsx exports PostponeDialog so the page can mount it
for quick-action postpone without duplicating UI.
- artifacts/tx-os/src/pages/executive-meetings.tsx: Schedule wires
swapTimes/quickMoveUp/quickMoveDown/postpone state, computes per-day
neighbours via meetingNumbersById, and mounts a single page-level
PostponeDialog. MeetingRow wraps `<tr>` in a Popover/PopoverAnchor; row
onClick opens the popover with skip rules for buttons/inputs/contenteditable
and ARIA roles (button/checkbox/switch/combobox/dialog) plus testid
prefixes (em-row-grip/-actions/-select, em-edit-*, em-merge-edit-*, em-time-*)
so the time cell's inline editor and other affordances don't collide.
- en/ar locales gain executiveMeetings.quickActions.{label,moveUp,moveDown,postpone}.
Tests
- artifacts/api-server/tests/executive-meetings-swap-times.test.mjs (new):
happy path, 409 stale_meeting (with conflict actor), 400 different_dates,
400 no_time_window. Each scenario uses a distinct far-future date to avoid
daily_number races with seeded data.
- artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs (new):
drives the date input, verifies row click → popover, Move up swap reflected
in DB, and Postpone item opens the dialog.
Code review (architect) flagged an edit-mode conflict where the time cell
(role=button div) would also bubble to the row handler; fixed by adding ARIA
role and em-time-* skip rules. Architect also flagged keyboard-trigger gap
and edit-mode test gaps — proposed as follow-ups.
Other test failures in the repo (executive-meetings reorder, font-settings,
notes-share, service-orders) pre-date this change and are unrelated.
This commit is contained in:
@@ -55,7 +55,10 @@ import {
|
||||
getUserDisplay as getUserDisplayForNotify,
|
||||
EXECUTIVE_MEETING_NOTIFICATION_TYPES,
|
||||
} from "../lib/executive-meeting-notify";
|
||||
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
|
||||
import {
|
||||
ExecutiveMeetingsReorderBody,
|
||||
ExecutiveMeetingsSwapTimesBody,
|
||||
} from "@workspace/api-zod";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -2128,6 +2131,204 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// SWAP TIMES (#486 — schedule row quick-actions Move up / Move down)
|
||||
// =====================================================================
|
||||
//
|
||||
// Swaps the (startTime, endTime) tuple between two meetings on the
|
||||
// same date so the schedule's Time column stays visually anchored
|
||||
// (each row keeps its slot; the meetings rotate through the slots).
|
||||
// Daily numbering is recomputed via renumberDayByStartTime so the
|
||||
// `#` column matches the new chronological order. Both rows are
|
||||
// FOR UPDATE-locked inside one transaction in id-order to avoid
|
||||
// deadlocks when two browsers race on the same pair, and each row
|
||||
// carries an optimistic-lock token (expectedUpdatedAt{A,B}) so a
|
||||
// stale click returns 409 stale_meeting + a `conflict` payload that
|
||||
// names the offending row + last actor — same shape the
|
||||
// PostponeDialog already understands.
|
||||
router.post(
|
||||
"/executive-meetings/swap-times",
|
||||
requireExecutiveAccess,
|
||||
requireMutate,
|
||||
async (req, res): Promise<void> => {
|
||||
const data = parseBody(res, ExecutiveMeetingsSwapTimesBody, req.body);
|
||||
if (!data) return;
|
||||
if (data.aId === data.bId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "aId and bId must differ", code: "same_id" });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
type StaleConflict = {
|
||||
ok: false;
|
||||
status: 409;
|
||||
error: string;
|
||||
code: "stale_meeting";
|
||||
conflict: {
|
||||
id: number;
|
||||
currentStartTime: string | null;
|
||||
currentEndTime: string | null;
|
||||
currentStatus: string;
|
||||
lastModifiedAt: string;
|
||||
lastActor: {
|
||||
id: number | null;
|
||||
username: string | null;
|
||||
displayNameAr: string | null;
|
||||
displayNameEn: string | null;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
type TxResult =
|
||||
| { ok: true; meetingDate: string }
|
||||
| { ok: false; status: number; error: string; code: string }
|
||||
| StaleConflict;
|
||||
const result = await db.transaction(async (tx): Promise<TxResult> => {
|
||||
// Always lock by ascending id to prevent deadlocks when two
|
||||
// concurrent swap-times calls touch the same pair from opposite
|
||||
// sides (A↔B vs B↔A).
|
||||
const ids = [data.aId, data.bId].slice().sort((x, y) => x - y);
|
||||
const lockedRows = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
.where(inArray(executiveMeetingsTable.id, ids))
|
||||
.for("update");
|
||||
const aRow = lockedRows.find((r) => r.id === data.aId);
|
||||
const bRow = lockedRows.find((r) => r.id === data.bId);
|
||||
if (!aRow || !bRow) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
error: "Meeting not found",
|
||||
code: "not_found",
|
||||
};
|
||||
}
|
||||
if (aRow.meetingDate !== bRow.meetingDate) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "Meetings are on different dates",
|
||||
code: "different_dates",
|
||||
};
|
||||
}
|
||||
if (
|
||||
aRow.startTime == null ||
|
||||
aRow.endTime == null ||
|
||||
bRow.startTime == null ||
|
||||
bRow.endTime == null
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "Both meetings must have a scheduled time window to swap",
|
||||
code: "no_time_window",
|
||||
};
|
||||
}
|
||||
// Optimistic-lock check on each row. Same shape the postpone
|
||||
// and reschedule endpoints use (#283) so the popover can render
|
||||
// the same "X just changed this meeting" prompt.
|
||||
for (const [row, expected] of [
|
||||
[aRow, data.expectedUpdatedAtA] as const,
|
||||
[bRow, data.expectedUpdatedAtB] as const,
|
||||
]) {
|
||||
const observedIso = new Date(expected).toISOString();
|
||||
const currentIso = row.updatedAt.toISOString();
|
||||
if (observedIso !== currentIso) {
|
||||
let lastActor: StaleConflict["conflict"]["lastActor"] = null;
|
||||
if (row.updatedBy != null) {
|
||||
const [u] = await tx
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, row.updatedBy));
|
||||
lastActor = u ?? null;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
error: "Meeting was modified by someone else",
|
||||
code: "stale_meeting",
|
||||
conflict: {
|
||||
id: row.id,
|
||||
currentStartTime: row.startTime,
|
||||
currentEndTime: row.endTime,
|
||||
currentStatus: row.status,
|
||||
lastModifiedAt: currentIso,
|
||||
lastActor,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// Snapshot the original windows so the swap is symmetric even
|
||||
// if Drizzle's `set` evaluates lazily.
|
||||
const aStart = aRow.startTime;
|
||||
const aEnd = aRow.endTime;
|
||||
const bStart = bRow.startTime;
|
||||
const bEnd = bRow.endTime;
|
||||
await tx
|
||||
.update(executiveMeetingsTable)
|
||||
.set({ startTime: bStart, endTime: bEnd, updatedBy: userId })
|
||||
.where(eq(executiveMeetingsTable.id, aRow.id));
|
||||
await tx
|
||||
.update(executiveMeetingsTable)
|
||||
.set({ startTime: aStart, endTime: aEnd, updatedBy: userId })
|
||||
.where(eq(executiveMeetingsTable.id, bRow.id));
|
||||
await logAudit(tx, {
|
||||
action: "meeting_swap_times",
|
||||
entityType: "meeting",
|
||||
entityId: aRow.id,
|
||||
oldValue: { startTime: aStart, endTime: aEnd },
|
||||
newValue: {
|
||||
startTime: bStart,
|
||||
endTime: bEnd,
|
||||
swappedWith: bRow.id,
|
||||
},
|
||||
performedBy: userId,
|
||||
});
|
||||
await logAudit(tx, {
|
||||
action: "meeting_swap_times",
|
||||
entityType: "meeting",
|
||||
entityId: bRow.id,
|
||||
oldValue: { startTime: bStart, endTime: bEnd },
|
||||
newValue: {
|
||||
startTime: aStart,
|
||||
endTime: aEnd,
|
||||
swappedWith: aRow.id,
|
||||
},
|
||||
performedBy: userId,
|
||||
});
|
||||
// Re-sort daily_number by start time so the schedule's `#`
|
||||
// column matches the new chronological order.
|
||||
await renumberDayByStartTime(tx, aRow.meetingDate);
|
||||
return { ok: true, meetingDate: aRow.meetingDate };
|
||||
});
|
||||
if (!result.ok) {
|
||||
const payload: Record<string, unknown> = {
|
||||
error: result.error,
|
||||
code: result.code,
|
||||
};
|
||||
if (result.code === "stale_meeting" && "conflict" in result) {
|
||||
payload.conflict = result.conflict;
|
||||
}
|
||||
res.status(result.status).json(payload);
|
||||
return;
|
||||
}
|
||||
void emitExecutiveMeetingsDayChanged(result.meetingDate);
|
||||
const [updatedA, updatedB] = await Promise.all([
|
||||
fetchMeetingWithAttendees(data.aId),
|
||||
fetchMeetingWithAttendees(data.bId),
|
||||
]);
|
||||
res.json({
|
||||
ok: true,
|
||||
meetings: [updatedA, updatedB].filter((m) => m != null),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// REORDER (within a single day)
|
||||
// =====================================================================
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// #486: Backend test for POST /executive-meetings/swap-times.
|
||||
//
|
||||
// The schedule row quick-actions popover (Move up / Move down) calls
|
||||
// this endpoint to swap just the (startTime, endTime) tuple between
|
||||
// the clicked meeting and its chronological neighbour. The Time
|
||||
// column stays visually anchored — each row keeps its slot and the
|
||||
// meetings rotate through the slots — and daily numbers are
|
||||
// recomputed by start time.
|
||||
//
|
||||
// Cases:
|
||||
// 1. Happy path: A and B on same date with valid updatedAt tokens
|
||||
// → 200, times swapped, dailyNumber re-sorted, both audit rows
|
||||
// written.
|
||||
// 2. Stale token on either row → 409 stale_meeting + conflict
|
||||
// payload that names the offending row + last actor.
|
||||
// 3. Different dates → 400 different_dates.
|
||||
// 4. Missing time window → 400 no_time_window.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
const created = { userIds: [], meetingIds: [] };
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, roleName, displayNameEn) {
|
||||
const username = uniqueName(prefix);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayNameEn],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
created.userIds.push(id);
|
||||
for (const r of ["user", roleName]) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[id, r],
|
||||
);
|
||||
}
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
function extractCookie(res) {
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (!setCookie) return null;
|
||||
return (
|
||||
setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid=")) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login should succeed for ${username}`);
|
||||
const cookie = extractCookie(res);
|
||||
assert.ok(cookie, "login response should set a session cookie");
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function api(cookie, method, path, body) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return fetch(`${API_BASE}${path}`, init);
|
||||
}
|
||||
|
||||
let userA = null;
|
||||
let cookieA = null;
|
||||
let userB = null;
|
||||
let cookieB = null;
|
||||
|
||||
before(async () => {
|
||||
userA = await createUser("em_swap_a", "executive_coord_lead", "Alice Swap");
|
||||
cookieA = await login(userA.username, TEST_PASSWORD);
|
||||
userB = await createUser("em_swap_b", "executive_coord_lead", "Bob Swap");
|
||||
cookieB = await login(userB.username, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (created.meetingIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type = 'meeting'`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||||
[created.meetingIds],
|
||||
);
|
||||
}
|
||||
if (created.userIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY($1::int[])`,
|
||||
[created.userIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
// Use distinct far-future dates per test so each scenario gets a
|
||||
// clean day — avoids any interference with seeded recurring meetings,
|
||||
// other suites running in the same DB, and the `executive_meetings_
|
||||
// date_number_unique` constraint that nextDailyNumber races against.
|
||||
function futureDate(offsetDays) {
|
||||
return new Date(Date.now() + offsetDays * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
const DATE_HAPPY = futureDate(30);
|
||||
const DATE_STALE = futureDate(31);
|
||||
const DATE_DIFF_A = futureDate(32);
|
||||
const DATE_DIFF_B = futureDate(33);
|
||||
const DATE_NO_WINDOW = futureDate(34);
|
||||
|
||||
async function createMeeting({ date, titleEn, startTime, endTime }) {
|
||||
const res = await api(cookieA, "POST", "/api/executive-meetings", {
|
||||
titleAr: titleEn,
|
||||
titleEn,
|
||||
meetingDate: date,
|
||||
startTime,
|
||||
endTime,
|
||||
status: "scheduled",
|
||||
});
|
||||
if (res.status !== 200 && res.status !== 201) {
|
||||
const body = await res.text();
|
||||
assert.fail(
|
||||
`meeting create should succeed (got ${res.status}): ${body.slice(0, 400)}`,
|
||||
);
|
||||
}
|
||||
const body = await res.json();
|
||||
const meeting = body.meeting ?? body;
|
||||
created.meetingIds.push(meeting.id);
|
||||
return meeting;
|
||||
}
|
||||
|
||||
async function fetchMeeting(cookie, id) {
|
||||
const res = await api(cookie, "GET", `/api/executive-meetings/${id}`);
|
||||
assert.equal(res.status, 200, "meeting fetch should succeed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
test("swap-times: happy path swaps (startTime, endTime) and re-sorts daily numbers", async () => {
|
||||
const a = await createMeeting({
|
||||
date: DATE_HAPPY,
|
||||
titleEn: "Swap A",
|
||||
startTime: "09:00",
|
||||
endTime: "09:30",
|
||||
});
|
||||
const b = await createMeeting({
|
||||
date: DATE_HAPPY,
|
||||
titleEn: "Swap B",
|
||||
startTime: "10:00",
|
||||
endTime: "10:30",
|
||||
});
|
||||
const aFresh = await fetchMeeting(cookieA, a.id);
|
||||
const bFresh = await fetchMeeting(cookieA, b.id);
|
||||
const beforeNumA = aFresh.dailyNumber;
|
||||
const beforeNumB = bFresh.dailyNumber;
|
||||
assert.notEqual(
|
||||
beforeNumA,
|
||||
beforeNumB,
|
||||
"two meetings on the same day should have distinct daily numbers",
|
||||
);
|
||||
|
||||
const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
|
||||
aId: a.id,
|
||||
bId: b.id,
|
||||
expectedUpdatedAtA: aFresh.updatedAt,
|
||||
expectedUpdatedAtB: bFresh.updatedAt,
|
||||
});
|
||||
assert.equal(res.status, 200, "swap should succeed");
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.meetings.length, 2);
|
||||
|
||||
const aAfter = await fetchMeeting(cookieA, a.id);
|
||||
const bAfter = await fetchMeeting(cookieA, b.id);
|
||||
// Times swapped — A now holds B's old window and vice versa.
|
||||
assert.equal(aAfter.startTime.slice(0, 5), "10:00");
|
||||
assert.equal(aAfter.endTime.slice(0, 5), "10:30");
|
||||
assert.equal(bAfter.startTime.slice(0, 5), "09:00");
|
||||
assert.equal(bAfter.endTime.slice(0, 5), "09:30");
|
||||
// Daily numbers re-sorted by start time, so A and B exchanged
|
||||
// positions in the `#` column too.
|
||||
assert.equal(aAfter.dailyNumber, beforeNumB);
|
||||
assert.equal(bAfter.dailyNumber, beforeNumA);
|
||||
|
||||
// Both rows audited.
|
||||
const { rows } = await pool.query(
|
||||
`SELECT entity_id FROM executive_meeting_audit_logs
|
||||
WHERE entity_type = 'meeting'
|
||||
AND action = 'meeting_swap_times'
|
||||
AND entity_id = ANY($1::int[])`,
|
||||
[[a.id, b.id]],
|
||||
);
|
||||
const auditedIds = rows.map((r) => r.entity_id).sort();
|
||||
assert.deepEqual(auditedIds, [a.id, b.id].sort());
|
||||
});
|
||||
|
||||
test("swap-times: stale updatedAt returns 409 stale_meeting with conflict payload", async () => {
|
||||
const a = await createMeeting({
|
||||
date: DATE_STALE,
|
||||
titleEn: "Stale A",
|
||||
startTime: "11:00",
|
||||
endTime: "11:30",
|
||||
});
|
||||
const b = await createMeeting({
|
||||
date: DATE_STALE,
|
||||
titleEn: "Stale B",
|
||||
startTime: "12:00",
|
||||
endTime: "12:30",
|
||||
});
|
||||
const aFresh = await fetchMeeting(cookieA, a.id);
|
||||
const bFresh = await fetchMeeting(cookieA, b.id);
|
||||
// Bob mutates B in between A's read and A's swap, invalidating the
|
||||
// updatedAt token Alice captured.
|
||||
const patch = await api(cookieB, "PATCH", `/api/executive-meetings/${b.id}`, {
|
||||
titleEn: "Stale B (renamed)",
|
||||
});
|
||||
assert.ok(
|
||||
patch.status === 200 || patch.status === 204,
|
||||
`patch should succeed (got ${patch.status})`,
|
||||
);
|
||||
const swap = await api(
|
||||
cookieA,
|
||||
"POST",
|
||||
"/api/executive-meetings/swap-times",
|
||||
{
|
||||
aId: a.id,
|
||||
bId: b.id,
|
||||
expectedUpdatedAtA: aFresh.updatedAt,
|
||||
expectedUpdatedAtB: bFresh.updatedAt,
|
||||
},
|
||||
);
|
||||
assert.equal(swap.status, 409);
|
||||
const body = await swap.json();
|
||||
assert.equal(body.code, "stale_meeting");
|
||||
assert.ok(body.conflict, "stale response must include conflict payload");
|
||||
assert.equal(body.conflict.id, b.id);
|
||||
assert.ok(body.conflict.lastActor, "conflict must name an actor");
|
||||
assert.equal(body.conflict.lastActor.id, userB.id);
|
||||
});
|
||||
|
||||
test("swap-times: different dates → 400 different_dates", async () => {
|
||||
const a = await createMeeting({
|
||||
date: DATE_DIFF_A,
|
||||
titleEn: "Date A",
|
||||
startTime: "13:00",
|
||||
endTime: "13:30",
|
||||
});
|
||||
const b = await createMeeting({
|
||||
date: DATE_DIFF_B,
|
||||
titleEn: "Date B",
|
||||
startTime: "13:00",
|
||||
endTime: "13:30",
|
||||
});
|
||||
const aFresh = await fetchMeeting(cookieA, a.id);
|
||||
const bFresh = await fetchMeeting(cookieA, b.id);
|
||||
const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
|
||||
aId: a.id,
|
||||
bId: b.id,
|
||||
expectedUpdatedAtA: aFresh.updatedAt,
|
||||
expectedUpdatedAtB: bFresh.updatedAt,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.code, "different_dates");
|
||||
});
|
||||
|
||||
test("swap-times: meeting without a time window → 400 no_time_window", async () => {
|
||||
// Create A normally, then null out B's start/end directly so we can
|
||||
// exercise the guard without going through PATCH (which validates).
|
||||
const a = await createMeeting({
|
||||
date: DATE_NO_WINDOW,
|
||||
titleEn: "Window A",
|
||||
startTime: "15:00",
|
||||
endTime: "15:30",
|
||||
});
|
||||
const b = await createMeeting({
|
||||
date: DATE_NO_WINDOW,
|
||||
titleEn: "Window B",
|
||||
startTime: "16:00",
|
||||
endTime: "16:30",
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE executive_meetings SET start_time = NULL, end_time = NULL WHERE id = $1`,
|
||||
[b.id],
|
||||
);
|
||||
const aFresh = await fetchMeeting(cookieA, a.id);
|
||||
const bFresh = await fetchMeeting(cookieA, b.id);
|
||||
const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
|
||||
aId: a.id,
|
||||
bId: b.id,
|
||||
expectedUpdatedAtA: aFresh.updatedAt,
|
||||
expectedUpdatedAtB: bFresh.updatedAt,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.code, "no_time_window");
|
||||
});
|
||||
Reference in New Issue
Block a user