2026-04-28 08:02:41 +00:00
import { test , before , after } from "node:test" ;
import assert from "node:assert/strict" ;
2026-05-03 14:59:23 +00:00
import zlib from "node:zlib" ;
2026-05-11 18:49:10 +00:00
import { readFileSync } from "node:fs" ;
import { fileURLToPath } from "node:url" ;
import path from "node:path" ;
2026-04-28 08:02:41 +00:00
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 ) {
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, 'EM Test', 'en', true) RETURNING id ` ,
[ username , ` ${ username } @example.com ` , TEST _PASSWORD _HASH ] ,
) ;
const id = rows [ 0 ] . id ;
created . userIds . push ( id ) ;
2026-04-28 08:14:01 +00:00
for ( const r of [ "user" , roleName ] ) {
2026-04-28 08:02:41 +00:00
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 adminCookie = null ;
let adminUserId = null ;
let coordCookie = null ;
let coordUserId = null ;
let leadCookie = null ;
let leadUserId = null ;
before ( async ( ) => {
adminCookie = await login ( "admin" , "admin123" ) ;
const meRes = await api ( adminCookie , "GET" , "/api/auth/me" ) ;
const me = await meRes . json ( ) ;
adminUserId = me . id ? ? me . userId ;
const coord = await createUser ( "em_coord" , "executive_coordinator" ) ;
coordUserId = coord . id ;
coordCookie = await login ( coord . username , TEST _PASSWORD ) ;
const lead = await createUser ( "em_lead" , "executive_coord_lead" ) ;
leadUserId = lead . id ;
leadCookie = await login ( lead . 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 ,
] ) ;
2026-05-01 08:18:29 +00:00
await pool . query ( ` DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY( $ 1::int[]) AND entity_type = 'meeting' ` , [
2026-04-28 08:02:41 +00:00
created . meetingIds ,
] ) ;
await pool . query ( ` DELETE FROM executive_meetings WHERE id = ANY( $ 1::int[]) ` , [
created . meetingIds ,
] ) ;
}
if ( created . userIds . length > 0 ) {
2026-04-30 18:56:05 +00:00
// Notification prefs are owned by users with ON DELETE CASCADE,
// but be explicit so we don't rely on the cascade if the FK is ever
// changed.
await pool . query (
` DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY( $ 1::int[]) ` ,
[ created . userIds ] ,
) ;
2026-04-28 08:02:41 +00:00
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 ( ) ;
} ) ;
const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
const tomorrow = new Date ( Date . now ( ) + 24 * 60 * 60 * 1000 )
. toISOString ( )
. slice ( 0 , 10 ) ;
2026-05-01 08:18:29 +00:00
test ( "GET /me exposes the surviving capability flags" , async ( ) => {
2026-04-28 08:02:41 +00:00
const res = await api ( adminCookie , "GET" , "/api/executive-meetings/me" ) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
for ( const key of [
"userId" ,
"roles" ,
"canRead" ,
"canMutate" ,
2026-05-01 08:36:43 +00:00
"canEditGlobalFontSettings" ,
2026-04-28 08:02:41 +00:00
"canViewAudit" ,
] ) {
assert . ok ( key in body , ` missing ${ key } in /me response ` ) ;
}
2026-05-01 08:36:43 +00:00
for ( const removed of [
"canApprove" ,
"canSubmitRequest" ,
"canViewTasks" ,
"canViewAllTasks" ,
] ) {
2026-05-01 08:18:29 +00:00
assert . ok ( ! ( removed in body ) , ` unexpected ${ removed } in /me response ` ) ;
}
2026-05-01 08:36:43 +00:00
assert . equal ( body . canEditGlobalFontSettings , true ) ;
2026-04-28 08:02:41 +00:00
const coordRes = await api ( coordCookie , "GET" , "/api/executive-meetings/me" ) ;
const coordMe = await coordRes . json ( ) ;
2026-05-01 08:18:29 +00:00
assert . equal ( coordMe . canRead , true ) ;
2026-05-01 08:36:43 +00:00
assert . equal ( coordMe . canEditGlobalFontSettings , false ) ;
2026-04-28 08:14:01 +00:00
} ) ;
test ( "Meetings: bilingual title required (titleEn cannot be empty)" , async ( ) => {
const missingEn = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع" ,
meetingDate : today ,
} ) ;
assert . equal ( missingEn . status , 400 ) ;
const emptyEn = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع" ,
titleEn : "" ,
meetingDate : today ,
} ) ;
assert . equal ( emptyEn . status , 400 ) ;
2026-04-28 08:02:41 +00:00
} ) ;
2026-04-28 08:14:01 +00:00
test ( "Meetings: POST → GET day → DELETE roundtrip" , async ( ) => {
2026-04-28 08:02:41 +00:00
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع اختبار" ,
titleEn : "Test meeting" ,
meetingDate : today ,
startTime : "09:00" ,
endTime : "10:00" ,
platform : "none" ,
status : "scheduled" ,
isHighlighted : 0 ,
attendees : [
{ name : "Tester One" , attendanceType : "internal" , sortOrder : 0 } ,
] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
assert . ok ( meeting . id ) ;
created . meetingIds . push ( meeting . id ) ;
const day = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings?date= ${ today } ` ,
) ;
assert . equal ( day . status , 200 ) ;
const dayBody = await day . json ( ) ;
assert . ok ( Array . isArray ( dayBody . meetings ) ) ;
assert . ok ( dayBody . meetings . some ( ( m ) => m . id === meeting . id ) ) ;
const del = await api (
adminCookie ,
"DELETE" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
assert . ok ( del . status === 200 || del . status === 204 ) ;
} ) ;
2026-04-28 08:14:01 +00:00
test ( "Meetings: PUT /attendees replaces the attendee list" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ا " ,
titleEn : "A" ,
meetingDate : today ,
attendees : [ { name : "Old One" , attendanceType : "internal" , sortOrder : 0 } ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const replace = await api (
adminCookie ,
"PUT" ,
` /api/executive-meetings/ ${ meeting . id } /attendees ` ,
{
attendees : [
{ name : "New One" , title : "Director" , attendanceType : "internal" , sortOrder : 0 } ,
{ name : "New Two" , title : "Manager" , attendanceType : "virtual" , sortOrder : 1 } ,
] ,
} ,
) ;
assert . equal ( replace . status , 200 ) ;
const fetched = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
const body = await fetched . json ( ) ;
assert . equal ( body . attendees . length , 2 ) ;
assert . ok ( body . attendees . find ( ( a ) => a . name === "New One" ) ) ;
assert . ok ( ! body . attendees . find ( ( a ) => a . name === "Old One" ) ) ;
} ) ;
2026-05-14 06:23:49 +00:00
// --- Attendee payload contract ---------------------------------
2026-04-30 19:16:00 +00:00
// Server is `.strict()` on the attendee schema so client-only fields like
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
// loudly with 400 instead of being silently stripped. This locks down the
// wire contract so a future client refactor that accidentally serializes
// `_sid` (or any other internal field) breaks tests immediately rather
// than being absorbed by lenient parsing.
test ( "Attendee payload contract: POST rejects unknown fields like `_sid`" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "س" ,
titleEn : "S" ,
meetingDate : today ,
attendees : [
{
name : "Leaky One" ,
attendanceType : "internal" ,
sortOrder : 0 ,
_sid : "client-only-row-id" ,
} ,
] ,
} ) ;
assert . equal (
create . status ,
400 ,
` expected 400 when attendee carries _sid, got ${ create . status } ` ,
) ;
const body = await create . json ( ) ;
// Zod surfaces the rejected key in the error path.
const serialized = JSON . stringify ( body ) ;
assert . ok (
serialized . includes ( "_sid" ) || serialized . toLowerCase ( ) . includes ( "unrecognized" ) ,
` expected error to mention the rejected key, got: ${ serialized } ` ,
) ;
} ) ;
test ( "Attendee payload contract: PATCH /:id rejects unknown attendee fields like `_sid`" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ت" ,
titleEn : "T" ,
meetingDate : today ,
attendees : [ { name : "Patch Seed" , attendanceType : "internal" , sortOrder : 0 } ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const patch = await api (
adminCookie ,
"PATCH" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
{
attendees : [
{
name : "Leaky Three" ,
attendanceType : "internal" ,
sortOrder : 0 ,
_sid : "client-only-row-id" ,
} ,
] ,
} ,
) ;
assert . equal (
patch . status ,
400 ,
` expected 400 when PATCH attendee carries _sid, got ${ patch . status } ` ,
) ;
// The seed attendee must still be the only one stored — the rejected
// PATCH must not have replaced the attendee list.
const fetched = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
const body = await fetched . json ( ) ;
assert . equal ( body . attendees . length , 1 ) ;
assert . equal ( body . attendees [ 0 ] . name , "Patch Seed" ) ;
} ) ;
test ( "Attendee payload contract: PUT /attendees rejects unknown fields like `_sid`" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ش" ,
titleEn : "Sh" ,
meetingDate : today ,
attendees : [ { name : "Seed" , attendanceType : "internal" , sortOrder : 0 } ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const replace = await api (
adminCookie ,
"PUT" ,
` /api/executive-meetings/ ${ meeting . id } /attendees ` ,
{
attendees : [
{
name : "Leaky Two" ,
attendanceType : "internal" ,
sortOrder : 0 ,
_sid : "client-only-row-id" ,
} ,
] ,
} ,
) ;
assert . equal (
replace . status ,
400 ,
` expected 400 when PUT attendee carries _sid, got ${ replace . status } ` ,
) ;
// And the meeting's attendee list must not have changed (the seed row
// still wins, the leaky row never persists).
const fetched = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
const body = await fetched . json ( ) ;
assert . equal ( body . attendees . length , 1 ) ;
assert . equal ( body . attendees [ 0 ] . name , "Seed" ) ;
} ) ;
2026-04-28 08:14:01 +00:00
test ( "Meetings: POST /duplicate clones a meeting onto another date" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" ,
titleEn : "B" ,
meetingDate : today ,
attendees : [ { name : "Dup Att" , attendanceType : "internal" , sortOrder : 0 } ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const original = await create . json ( ) ;
created . meetingIds . push ( original . id ) ;
const dup = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ original . id } /duplicate ` ,
{ targetDate : tomorrow } ,
) ;
assert . equal ( dup . status , 201 ) ;
const newRow = await dup . json ( ) ;
assert . notEqual ( newRow . id , original . id ) ;
created . meetingIds . push ( newRow . id ) ;
const day = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings?date= ${ tomorrow } ` ,
) ;
const dayBody = await day . json ( ) ;
assert . ok ( dayBody . meetings . some ( ( m ) => m . id === newRow . id ) ) ;
} ) ;
2026-04-30 23:31:57 +00:00
// #189: plain-text sanitization for location / meetingUrl / notes.
2026-04-30 20:58:11 +00:00
test ( "Sanitization: POST strips HTML from location, meetingUrl, and notes" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ل" ,
titleEn : "L" ,
meetingDate : today ,
location : '<script>alert("xss")</script>Conference Room A' ,
meetingUrl :
'<img src=x onerror=alert(1)>https://example.com/meet?a=1&b=2#room' ,
notes : '<b>bold</b> note: 5 < 10 & ok <script>bad()</script>' ,
attendees : [ ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
2026-04-30 23:31:57 +00:00
assert . ok ( ! /<script/i . test ( meeting . location ? ? "" ) ) ;
assert . ok ( ( meeting . location ? ? "" ) . includes ( "Conference Room A" ) ) ;
assert . ok ( ! /<img|onerror/i . test ( meeting . meetingUrl ? ? "" ) ) ;
assert . equal ( meeting . meetingUrl , "https://example.com/meet?a=1&b=2#room" ) ;
assert . ok ( ! /<script|<b>/i . test ( meeting . notes ? ? "" ) ) ;
assert . ok ( ( meeting . notes ? ? "" ) . includes ( "5 < 10" ) ) ;
assert . ok ( ( meeting . notes ? ? "" ) . includes ( "& ok" ) ) ;
assert . ok ( ! /&|<|>/ . test ( meeting . notes ? ? "" ) ) ;
2026-04-30 20:58:11 +00:00
} ) ;
2026-04-30 23:31:57 +00:00
// Regression: encoded payloads must NOT be decoded back into live tags.
2026-04-30 23:19:40 +00:00
test ( "Sanitization: encoded HTML payloads (<script>…) are NOT decoded into live tags" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ه " ,
titleEn : "H" ,
meetingDate : today ,
location : "<script>alert('loc')</script>Boardroom" ,
meetingUrl :
"<script>alert('url')</script>https://x.test/?a=1&b=2" ,
notes : "ok & safe — <img onerror=alert(1) src=x>" ,
attendees : [ ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
for ( const field of [ "location" , "meetingUrl" , "notes" ] ) {
const v = meeting [ field ] ? ? "" ;
2026-04-30 23:31:57 +00:00
assert . ok ( ! /<script|<img|<iframe|<\/script/i . test ( v ) ) ;
2026-04-30 23:19:40 +00:00
}
2026-04-30 23:31:57 +00:00
assert . ok ( ( meeting . location ? ? "" ) . includes ( "<script>" ) ) ;
assert . ok ( ( meeting . meetingUrl ? ? "" ) . includes ( "<script>" ) ) ;
2026-04-30 23:19:40 +00:00
assert . ok (
( meeting . notes ? ? "" ) . includes ( "&" ) &&
( meeting . notes ? ? "" ) . includes ( "<img" ) ,
) ;
const mixed = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "خ" ,
titleEn : "X" ,
meetingDate : today ,
location : "<ScRiPt>alert(1)</ScRiPt>Boardroom B" ,
notes : "<IFRAME src=evil>x</IFRAME>visible note" ,
attendees : [ ] ,
} ) ;
assert . equal ( mixed . status , 201 ) ;
const mm = await mixed . json ( ) ;
created . meetingIds . push ( mm . id ) ;
2026-04-30 23:31:57 +00:00
assert . ok ( ! /<script|<iframe/i . test ( ( mm . location ? ? "" ) + ( mm . notes ? ? "" ) ) ) ;
2026-04-30 23:19:40 +00:00
assert . ok ( ( mm . location ? ? "" ) . includes ( "Boardroom B" ) ) ;
assert . ok ( ( mm . notes ? ? "" ) . includes ( "visible note" ) ) ;
} ) ;
2026-04-30 20:58:11 +00:00
test ( "Sanitization: PATCH strips HTML from location, meetingUrl, and notes" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "م" ,
titleEn : "M" ,
meetingDate : today ,
location : "Original" ,
notes : "Original note" ,
attendees : [ ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const patch = await api (
adminCookie ,
"PATCH" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
{
location : '<script>steal()</script>Updated Room' ,
meetingUrl : '<a href="javascript:bad()">https://meet.example.com</a>' ,
notes : '<iframe src="x"></iframe>Updated note' ,
} ,
) ;
assert . equal ( patch . status , 200 ) ;
const fetched = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
const body = await fetched . json ( ) ;
assert . ok ( ! /<script/i . test ( body . location ? ? "" ) ) ;
assert . ok ( ( body . location ? ? "" ) . includes ( "Updated Room" ) ) ;
assert . ok ( ! /<a |<iframe|javascript:/i . test ( body . meetingUrl ? ? "" ) ) ;
assert . ok ( ( body . meetingUrl ? ? "" ) . includes ( "https://meet.example.com" ) ) ;
assert . ok ( ! /<iframe/i . test ( body . notes ? ? "" ) ) ;
assert . ok ( ( body . notes ? ? "" ) . includes ( "Updated note" ) ) ;
} ) ;
2026-04-30 23:31:57 +00:00
// #202: titleEn / titleAr are independent columns; saveTitle in
// executive-meetings.tsx routes by visible i18n.language, so a PATCH
// for one column must never clobber the other.
2026-04-30 20:58:11 +00:00
test ( "EditableCell contract: PATCH titleEn alone leaves titleAr untouched (and vice versa)" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "العنوان الأصلي" ,
titleEn : "Original Title" ,
meetingDate : today ,
attendees : [ ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const patchEn = await api (
adminCookie ,
"PATCH" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
{ titleEn : "<p>Edited English</p>" } ,
) ;
assert . equal ( patchEn . status , 200 ) ;
let fetched = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
let body = await fetched . json ( ) ;
2026-04-30 23:31:57 +00:00
assert . ok ( body . titleEn . includes ( "Edited English" ) ) ;
assert . ok ( body . titleAr . includes ( "العنوان الأصلي" ) ) ;
2026-04-30 20:58:11 +00:00
const patchAr = await api (
adminCookie ,
"PATCH" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
{ titleAr : "<p>عنوان معدل</p>" } ,
) ;
assert . equal ( patchAr . status , 200 ) ;
fetched = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
) ;
body = await fetched . json ( ) ;
assert . ok ( body . titleAr . includes ( "عنوان معدل" ) ) ;
2026-04-30 23:31:57 +00:00
assert . ok ( body . titleEn . includes ( "Edited English" ) ) ;
2026-04-30 20:58:11 +00:00
} ) ;
test ( "Sanitization: duplicate path re-sanitizes location/meetingUrl/notes and preserves URL ampersands" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "د" ,
titleEn : "D" ,
meetingDate : today ,
location : "Boardroom 7" ,
meetingUrl : "https://meet.example.com/x?room=42&token=abc" ,
notes : "Bring slides & notes" ,
attendees : [ ] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const original = await create . json ( ) ;
created . meetingIds . push ( original . id ) ;
const dup = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ original . id } /duplicate ` ,
{ targetDate : tomorrow } ,
) ;
assert . equal ( dup . status , 201 ) ;
const cloned = await dup . json ( ) ;
created . meetingIds . push ( cloned . id ) ;
2026-04-30 23:31:57 +00:00
assert . equal ( cloned . meetingUrl , "https://meet.example.com/x?room=42&token=abc" ) ;
assert . ok ( ( cloned . notes ? ? "" ) . includes ( "&" ) ) ;
2026-04-30 20:58:11 +00:00
assert . ok (
! /&/ . test ( cloned . meetingUrl ? ? "" ) && ! /&/ . test ( cloned . notes ? ? "" ) ,
) ;
} ) ;
2026-04-28 08:02:41 +00:00
test ( "Audit logs: admin can list, plain coordinator gets 403" , async ( ) => {
const ok = await api (
adminCookie ,
"GET" ,
"/api/executive-meetings/audit-logs" ,
) ;
assert . equal ( ok . status , 200 ) ;
const body = await ok . json ( ) ;
assert . ok ( Array . isArray ( body . logs ? ? body . auditLogs ? ? body . entries ? ? [ ] ) ) ;
const denied = await api (
coordCookie ,
"GET" ,
"/api/executive-meetings/audit-logs" ,
) ;
assert . equal ( denied . status , 403 ) ;
} ) ;
2026-04-28 08:14:01 +00:00
test ( "Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results" , async ( ) => {
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "س" ,
titleEn : "S" ,
meetingDate : today ,
} ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const filtered = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/audit-logs?dateFrom= ${ today } &dateTo= ${ today } &action=create&actorId= ${ adminUserId } &entityType=meeting ` ,
) ;
assert . equal ( filtered . status , 200 ) ;
const body = await filtered . json ( ) ;
const entries = body . entries ? ? body . logs ? ? [ ] ;
assert . ok ( Array . isArray ( entries ) ) ;
for ( const e of entries ) {
assert . equal ( e . action , "create" ) ;
assert . equal ( e . entityType , "meeting" ) ;
assert . equal ( e . performedBy , adminUserId ) ;
}
assert . ok ( entries . some ( ( e ) => e . entityId === meeting . id ) ) ;
} ) ;
2026-04-28 08:02:41 +00:00
test ( "Notifications: GET filters by ?date= and tolerates empty days" , async ( ) => {
const empty = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/notifications?date=1990-01-01 ` ,
) ;
assert . equal ( empty . status , 200 ) ;
const emptyBody = await empty . json ( ) ;
assert . ok ( Array . isArray ( emptyBody . notifications ) ) ;
const todayList = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/notifications?date= ${ today } ` ,
) ;
assert . equal ( todayList . status , 200 ) ;
} ) ;
2026-04-28 08:14:01 +00:00
test ( "Font settings: valid combo 200; invalid weight/size/family 400" , async ( ) => {
2026-04-28 08:02:41 +00:00
const ok = await api (
adminCookie ,
"PATCH" ,
"/api/executive-meetings/font-settings" ,
{
scope : "user" ,
2026-05-01 19:55:39 +00:00
fontFamily : "DIN Next LT Arabic" ,
2026-04-28 08:02:41 +00:00
fontSize : 16 ,
fontWeight : "bold" ,
alignment : "center" ,
} ,
) ;
assert . equal ( ok . status , 200 ) ;
const badWeight = await api (
adminCookie ,
"PATCH" ,
"/api/executive-meetings/font-settings" ,
{ scope : "user" , fontWeight : "medium" } ,
) ;
assert . equal ( badWeight . status , 400 ) ;
const badSize = await api (
adminCookie ,
"PATCH" ,
"/api/executive-meetings/font-settings" ,
{ scope : "user" , fontSize : 30 } ,
) ;
assert . equal ( badSize . status , 400 ) ;
const badFamily = await api (
adminCookie ,
"PATCH" ,
"/api/executive-meetings/font-settings" ,
{ scope : "user" , fontFamily : "Comic Sans" } ,
) ;
assert . equal ( badFamily . status , 400 ) ;
} ) ;
2026-05-03 15:03:54 +00:00
test ( "Font settings: user-scope rejects logoObjectPath with 400; null is allowed" , async ( ) => {
const rejected = await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "user" ,
fontFamily : "system" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "#000000" ,
logoObjectPath : "/objects/some-fake-id" ,
} ,
) ;
assert . equal ( rejected . status , 400 ) ;
const body = await rejected . json ( ) ;
assert . equal ( body . code , "logo_user_scope_forbidden" ) ;
2026-05-03 15:10:34 +00:00
// null is allowed on user rows.
2026-05-03 15:03:54 +00:00
const okNull = await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "user" ,
fontFamily : "system" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "#000000" ,
logoObjectPath : null ,
} ,
) ;
assert . equal ( okNull . status , 200 ) ;
} ) ;
2026-04-28 08:14:01 +00:00
test ( "PDF archives: POST creates a snapshot and GET returns it" , async ( ) => {
const post = await api (
adminCookie ,
"POST" ,
"/api/executive-meetings/pdf-archives" ,
{ archiveDate : today } ,
) ;
assert . equal ( post . status , 201 ) ;
const archive = await post . json ( ) ;
assert . ok ( archive . id ) ;
assert . ok ( archive . version >= 1 ) ;
const list = await api (
2026-04-28 08:02:41 +00:00
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf-archives?date= ${ today } ` ,
) ;
2026-04-28 08:14:01 +00:00
assert . equal ( list . status , 200 ) ;
const body = await list . json ( ) ;
const archives = body . archives ? ? body . items ? ? body . pdfArchives ? ? [ ] ;
assert . ok ( Array . isArray ( archives ) ) ;
assert . ok ( archives . some ( ( a ) => a . id === archive . id ) ) ;
2026-04-28 08:02:41 +00:00
} ) ;
2026-04-29 07:39:24 +00:00
2026-04-29 18:01:19 +00:00
test ( "PDF GET /executive-meetings/pdf returns a real PDF and archives it" , async ( ) => {
// Create a meeting with mixed Arabic+Latin attendees + a time range so
// the renderer's bidi/font-segmentation path is exercised.
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع الميزانية Q2 2026 " ,
titleEn : "Q2 2026 Budget Review" ,
meetingDate : today ,
startTime : "10:00" ,
endTime : "11:30" ,
location : "قاعة كبرى" ,
attendees : [
{ name : "أحمد العلي" , title : "المدير التنفيذي" ,
attendanceType : "internal" , sortOrder : 0 } ,
{ name : "John Smith" , title : "CFO" ,
attendanceType : "external" , sortOrder : 1 } ,
] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
// Bad date → 400
const bad = await api ( adminCookie , "GET" ,
"/api/executive-meetings/pdf?date=not-a-date" ) ;
assert . equal ( bad . status , 400 ) ;
// Coordinator without executive role is denied — auth gate matches the
// rest of the executive-meetings module.
const noAuth = await fetch ( ` ${ API _BASE } /api/executive-meetings/pdf?date= ${ today } ` ) ;
assert . equal ( noAuth . status , 401 ) ;
// Happy path: real PDF response with non-trivial body.
const res = await api ( adminCookie , "GET" ,
` /api/executive-meetings/pdf?date= ${ today } &lang=ar ` ) ;
assert . equal ( res . status , 200 ) ;
assert . match (
res . headers . get ( "content-type" ) || "" ,
/^application\/pdf/ ,
"must serve application/pdf" ,
) ;
assert . match (
res . headers . get ( "content-disposition" ) || "" ,
new RegExp ( ` attachment; \\ s*filename="executive-meetings- ${ today } \\ .pdf" ` ) ,
"filename should include the requested date" ,
) ;
const buf = Buffer . from ( await res . arrayBuffer ( ) ) ;
assert . ok ( buf . length > 1000 , ` PDF should be >1KB, got ${ buf . length } ` ) ;
assert . equal (
buf . subarray ( 0 , 4 ) . toString ( "ascii" ) ,
"%PDF" ,
"body must start with PDF magic bytes" ,
) ;
// The download must have created an archive row recorded against the
// generating user with byteSize matching the served body.
const list = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf-archives?date= ${ today } ` ,
) ;
assert . equal ( list . status , 200 ) ;
const archives = ( await list . json ( ) ) . archives ? ? [ ] ;
const generated = archives . find (
( a ) => typeof a . byteSize === "number" && a . byteSize === buf . length ,
) ;
assert . ok (
generated ,
"PDF download must produce an archive row with the served byte_size" ,
) ;
assert . ok ( generated . generatedBy , "generatedBy must be recorded" ) ;
assert . ok (
typeof generated . filePath === "string" && generated . filePath . length > 0 ,
"filePath/storage_url must be non-empty" ,
) ;
// Empty-day handling: another date with no meetings still returns a
// valid PDF (with a "no meetings" message).
const empty = await api ( adminCookie , "GET" ,
` /api/executive-meetings/pdf?date=2099-01-01&lang=en ` ) ;
assert . equal ( empty . status , 200 ) ;
const emptyBuf = Buffer . from ( await empty . arrayBuffer ( ) ) ;
assert . equal ( emptyBuf . subarray ( 0 , 4 ) . toString ( "ascii" ) , "%PDF" ) ;
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
2026-05-01 19:55:39 +00:00
// 2) The Sans family ("DIN Next LT Arabic") embeds NotoSansArabic,
// while the Naskh-style family ("Majalla") embeds NotoNaskhArabic.
// The PostScript name appears verbatim in the PDF font dictionary.
2026-04-29 18:01:19 +00:00
const setSans = await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "user" ,
2026-05-01 19:55:39 +00:00
fontFamily : "DIN Next LT Arabic" ,
2026-04-29 18:01:19 +00:00
fontSize : 16 ,
fontWeight : "bold" ,
alignment : "center" ,
} ,
) ;
assert . equal ( setSans . status , 200 ) ;
const sansRes = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf?date= ${ today } &lang=ar ` ,
) ;
assert . equal ( sansRes . status , 200 ) ;
const sansBuf = Buffer . from ( await sansRes . arrayBuffer ( ) ) ;
assert . equal ( sansBuf . subarray ( 0 , 4 ) . toString ( "ascii" ) , "%PDF" ) ;
const sansAscii = sansBuf . toString ( "latin1" ) ;
assert . ok (
/NotoSansArabic/i . test ( sansAscii ) ,
2026-05-01 19:55:39 +00:00
"DIN Next LT Arabic family must embed NotoSansArabic in the PDF" ,
2026-04-29 18:01:19 +00:00
) ;
assert . ok (
! /NotoNaskhArabic/i . test ( sansAscii ) ,
2026-05-01 19:55:39 +00:00
"DIN Next LT Arabic family must NOT embed NotoNaskhArabic" ,
2026-04-29 18:01:19 +00:00
) ;
const setNaskh = await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "user" ,
2026-05-01 19:55:39 +00:00
fontFamily : "Majalla" ,
2026-04-29 18:01:19 +00:00
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
} ,
) ;
assert . equal ( setNaskh . status , 200 ) ;
const naskhRes = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf?date= ${ today } &lang=ar ` ,
) ;
assert . equal ( naskhRes . status , 200 ) ;
const naskhBuf = Buffer . from ( await naskhRes . arrayBuffer ( ) ) ;
const naskhAscii = naskhBuf . toString ( "latin1" ) ;
assert . ok (
/NotoNaskhArabic/i . test ( naskhAscii ) ,
2026-05-01 19:55:39 +00:00
"Majalla family must embed NotoNaskhArabic in the PDF" ,
2026-04-29 18:01:19 +00:00
) ;
assert . ok (
! /NotoSansArabic/i . test ( naskhAscii ) ,
2026-05-01 19:55:39 +00:00
"Majalla family must NOT embed NotoSansArabic" ,
2026-04-29 18:01:19 +00:00
) ;
} ) ;
2026-05-03 14:43:22 +00:00
test ( "PDF content: title label, rowColor tint, dropped isHighlighted, fontColor" , async ( ) => {
2026-05-03 15:10:34 +00:00
// Inflate FlateDecode streams and assert title/tint/fontColor ops.
2026-05-03 14:43:22 +00:00
const zlib = await import ( "node:zlib" ) ;
const decodeStreams = ( buf ) => {
const out = [ ] ;
let cursor = 0 ;
while ( true ) {
const start = buf . indexOf ( "stream\n" , cursor ) ;
if ( start < 0 ) break ;
const end = buf . indexOf ( "\nendstream" , start ) ;
if ( end < 0 ) break ;
try {
out . push (
zlib
. inflateSync ( buf . slice ( start + "stream\n" . length , end ) )
. toString ( "latin1" ) ,
) ;
} catch {
// not a deflate stream (font/image bytes) — skip
}
cursor = end + "\nendstream" . length ;
}
return out . join ( "\n" ) ;
} ;
const pdfDate = "2099-05-03" ;
const ambered = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع تجريبي" ,
titleEn : "Tinted Meeting" ,
meetingDate : pdfDate ,
attendees : [ { name : "ع" , attendanceType : "internal" , sortOrder : 0 } ] ,
} ) ;
assert . equal ( ambered . status , 201 ) ;
const amberedId = ( await ambered . json ( ) ) . id ;
created . meetingIds . push ( amberedId ) ;
const tint = await api (
adminCookie ,
"PATCH" ,
` /api/executive-meetings/ ${ amberedId } ` ,
{ rowColor : "amber" } ,
) ;
assert . equal ( tint . status , 200 ) ;
const highlighted = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع آخر" ,
titleEn : "Highlighted Meeting" ,
meetingDate : pdfDate ,
isHighlighted : true ,
attendees : [ { name : "ب" , attendanceType : "internal" , sortOrder : 0 } ] ,
} ) ;
assert . equal ( highlighted . status , 201 ) ;
created . meetingIds . push ( ( await highlighted . json ( ) ) . id ) ;
2026-05-03 15:10:34 +00:00
// #336699 → 0.2/0.4/0.6 in PDFKit's rg op.
2026-05-03 14:43:22 +00:00
const setColor = await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "user" ,
fontFamily : "Majalla" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "#336699" ,
} ,
) ;
assert . equal ( setColor . status , 200 ) ;
const res = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf?date= ${ pdfDate } &lang=ar ` ,
) ;
assert . equal ( res . status , 200 ) ;
const buf = Buffer . from ( await res . arrayBuffer ( ) ) ;
const ascii = buf . toString ( "latin1" ) ;
const decoded = decodeStreams ( buf ) ;
2026-05-03 15:10:34 +00:00
// (1) /Title prefix as UTF-16BE for "قائم" (0x0642 0x0627 0x0626 0x0645).
2026-05-03 14:43:22 +00:00
const titlePrefix = Buffer . from ( [
0xfe , 0xff , 0x06 , 0x42 , 0x06 , 0x27 , 0x06 , 0x26 , 0x06 , 0x45 ,
] ) ;
assert . ok (
buf . includes ( titlePrefix ) ,
"PDF /Title metadata must start with the new Arabic title (قائم…)" ,
) ;
2026-05-03 15:10:34 +00:00
// Accept rg or scn (PDFKit varies by version).
2026-05-03 14:43:22 +00:00
const op = "(?:rg|scn)" ;
// (2) rowColor=amber → #fef3c7 = 254/255, 243/255, 199/255.
assert . match (
decoded ,
new RegExp ( ` 0 \\ .9960 \\ d* 0 \\ .9529 \\ d* 0 \\ .7803 \\ d* ${ op } ` ) ,
"amber rowColor must paint the saved palette fill" ,
) ;
2026-05-03 15:10:34 +00:00
// (3) Legacy isHighlighted fill (#fecaca) must NOT appear.
2026-05-03 14:43:22 +00:00
assert . doesNotMatch (
decoded ,
new RegExp ( ` 0 \\ .9960 \\ d* 0 \\ .7921 \\ d* 0 \\ .7921 \\ d* ${ op } ` ) ,
"isHighlighted must no longer be baked into the PDF" ,
) ;
2026-05-03 15:10:34 +00:00
// (4) fontColor #336699 must appear as a fill op.
2026-05-03 14:43:22 +00:00
assert . match (
decoded ,
new RegExp ( ` (?:^| \\ s)0 \\ .2 0 \\ .4 0 \\ .6 ${ op } ` ) ,
"user fontColor #336699 must appear as a fill operator" ,
) ;
2026-05-03 15:10:34 +00:00
void ascii ;
2026-05-03 14:43:22 +00:00
} ) ;
2026-05-11 18:49:10 +00:00
test ( "PDF Arabic shaping: renderer hands raw Unicode to PDFKit (no manual pre-shaping)" , async ( ) => {
2026-05-03 15:26:38 +00:00
// Regression for the bug where Arabic in the printed PDF appeared as
2026-05-11 18:49:10 +00:00
// disconnected, visually-reversed letters. The fix: let PDFKit/fontkit
// do the shaping + RTL reorder via `features: ['rtla','rclt',...]`,
// and DO NOT call shapeArabic() ourselves before drawing.
//
// Why we no longer scan the PDF binary for U+FExx codepoints:
// ToUnicode CMaps are emitted by fontkit and naturally include
// presentation-form codepoints when the embedded Arabic font's own
// cmap subtable maps glyphs back to those codepoints. That's normal
// font internals — not pre-shaping by us — so a presence-check on
// the inflated streams produced false positives.
//
// Instead we do two things:
// 1) End-to-end: render a real PDF for an Arabic meeting and assert
// the response is a non-trivial PDF that references at least one
// base Arabic codepoint somewhere in its inflated streams (proves
// Arabic content was drawn).
// 2) Source-level guard: assert pdf-renderer.ts never CALLS
// shapeArabic() — the only allowed mention is its own export
// definition. This is the precise signal we care about.
2026-05-03 15:26:38 +00:00
const shapedDate = "2099-05-04" ;
const shapedMeeting = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اجتماع الاختبار" ,
titleEn : "Shaping Smoke Test" ,
meetingDate : shapedDate ,
attendees : [
{ name : "محمد عبدالله" , attendanceType : "internal" , sortOrder : 0 } ,
] ,
} ) ;
assert . equal ( shapedMeeting . status , 201 ) ;
created . meetingIds . push ( ( await shapedMeeting . json ( ) ) . id ) ;
const res = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf?date= ${ shapedDate } &lang=ar ` ,
) ;
assert . equal ( res . status , 200 ) ;
const buf = Buffer . from ( await res . arrayBuffer ( ) ) ;
2026-05-11 18:49:10 +00:00
assert . ok ( buf . length > 1000 , "rendered PDF must be a real, non-trivial document" ) ;
assert . ok (
buf . slice ( 0 , 5 ) . toString ( "latin1" ) === "%PDF-" ,
"response must start with the %PDF- magic header" ,
) ;
2026-05-03 15:26:38 +00:00
2026-05-11 18:49:10 +00:00
// (1) Inflate streams and confirm at least one base Arabic codepoint
// appears — proves Arabic content was drawn through the pipeline.
2026-05-03 15:26:38 +00:00
let cursor = 0 ;
const decoded = [ ] ;
while ( true ) {
const start = buf . indexOf ( "stream\n" , cursor ) ;
if ( start < 0 ) break ;
const end = buf . indexOf ( "\nendstream" , start ) ;
if ( end < 0 ) break ;
try {
decoded . push (
zlib
. inflateSync ( buf . slice ( start + "stream\n" . length , end ) )
. toString ( "latin1" ) ,
) ;
} catch {
// not a deflate stream
}
cursor = end + "\nendstream" . length ;
}
const allDecoded = decoded . join ( "\n" ) ;
assert . match (
allDecoded ,
2026-05-11 18:49:10 +00:00
/<(?:06[0-9A-F]{2})>/i ,
"rendered PDF must reference base Arabic codepoints somewhere in its streams" ,
) ;
// (2) Source-level guard: pdf-renderer.ts must not call shapeArabic().
// The only allowed appearance is the export's own signature.
const here = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const rendererPath = path . resolve ( here , ".." , "src" , "lib" , "pdf-renderer.ts" ) ;
const src = readFileSync ( rendererPath , "utf8" ) ;
// Hard-pin the contract: there must be exactly one definition of
// `shapeArabic` in this file (export form). If someone refactors it
// away or duplicates it, this guard fails fast instead of silently
// letting an alias slip through.
const defMatches = src . match ( /^export\s+function\s+shapeArabic\b/gm ) ? ? [ ] ;
assert . equal (
defMatches . length ,
1 ,
` pdf-renderer.ts must declare exactly one \` export function shapeArabic \` — found ${ defMatches . length } . Update this guard if the contract intentionally changed. ` ,
) ;
// Strip the single definition (matched at start-of-line, balanced via
// the closing brace at column 0) and assert the symbol is never
// CALLED elsewhere — `name(` syntax including whitespace/comments.
const withoutDef = src . replace (
/^export\s+function\s+shapeArabic\b[\s\S]*?^\}\s*$/m ,
"" ,
) ;
// Match `shapeArabic` followed by optional whitespace/block-comments
// then an opening paren — covers `shapeArabic(` and `shapeArabic /**/(`.
const callRegex = /\bshapeArabic\b(?:\s|\/\*[\s\S]*?\*\/)*\(/ ;
const callMatch = withoutDef . match ( callRegex ) ;
2026-05-03 19:01:50 +00:00
assert . equal (
2026-05-11 18:49:10 +00:00
callMatch ,
2026-05-03 19:01:50 +00:00
null ,
2026-05-11 18:49:10 +00:00
` pdf-renderer.ts must NOT call shapeArabic() — fontkit handles shaping via Arabic OpenType features. Re-introducing a pre-shaping pass causes the visually-reversed disconnected-letters regression. Found call: ${ callMatch ? . [ 0 ] } ` ,
2026-05-03 15:26:38 +00:00
) ;
} ) ;
2026-05-03 14:59:23 +00:00
test ( "PDF embeds the brand logo when set in global font settings" , async ( ) => {
2026-05-03 15:10:34 +00:00
// Build a real 1x1 RGB PNG (png-js rejects most base64 fixtures).
2026-05-03 14:59:23 +00:00
const u32 = ( n ) => {
const b = Buffer . alloc ( 4 ) ;
b . writeUInt32BE ( n , 0 ) ;
return b ;
} ;
const crc32 = ( buf ) => {
const table = new Array ( 256 ) ;
for ( let n = 0 ; n < 256 ; n ++ ) {
let c = n ;
for ( let k = 0 ; k < 8 ; k ++ )
c = c & 1 ? 0xedb88320 ^ ( c >>> 1 ) : c >>> 1 ;
table [ n ] = c >>> 0 ;
}
let crc = 0xffffffff ;
for ( let i = 0 ; i < buf . length ; i ++ )
crc = ( table [ ( crc ^ buf [ i ] ) & 0xff ] ^ ( crc >>> 8 ) ) >>> 0 ;
return ( crc ^ 0xffffffff ) >>> 0 ;
} ;
const mkChunk = ( type , data ) => {
const typeBuf = Buffer . from ( type , "ascii" ) ;
const crc = crc32 ( Buffer . concat ( [ typeBuf , data ] ) ) ;
return Buffer . concat ( [ u32 ( data . length ) , typeBuf , data , u32 ( crc ) ] ) ;
} ;
const ihdr = Buffer . concat ( [ u32 ( 1 ) , u32 ( 1 ) , Buffer . from ( [ 8 , 2 , 0 , 0 , 0 ] ) ] ) ;
const idat = zlib . deflateSync ( Buffer . from ( [ 0 , 0 , 255 , 0 ] ) ) ;
const tinyPng = Buffer . concat ( [
Buffer . from ( [ 0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a ] ) ,
mkChunk ( "IHDR" , ihdr ) ,
mkChunk ( "IDAT" , idat ) ,
mkChunk ( "IEND" , Buffer . alloc ( 0 ) ) ,
] ) ;
2026-05-03 15:10:34 +00:00
// 1) Sign upload URL.
2026-05-03 14:59:23 +00:00
const signRes = await api (
adminCookie ,
"POST" ,
"/api/storage/uploads/request-url" ,
{
name : "tx-os-test-logo.png" ,
size : tinyPng . byteLength ,
contentType : "image/png" ,
} ,
) ;
assert . equal ( signRes . status , 200 , "must be able to sign an upload URL" ) ;
const { uploadURL , objectPath } = await signRes . json ( ) ;
assert . ok ( /^\/objects\// . test ( objectPath ) , ` expected /objects/<id>, got ${ objectPath } ` ) ;
2026-05-03 15:10:34 +00:00
// 2) PUT bytes; skip on network/5xx, fail on 4xx.
2026-05-03 14:59:23 +00:00
let putRes ;
try {
putRes = await fetch ( uploadURL , {
method : "PUT" ,
body : tinyPng ,
headers : {
"Content-Type" : "image/png" ,
"Content-Length" : String ( tinyPng . byteLength ) ,
} ,
} ) ;
} catch ( err ) {
console . warn (
` [test] storage sidecar unreachable, skipping logo embed test: ${ err . message } ` ,
) ;
return ;
}
if ( putRes . status >= 500 ) {
console . warn (
` [test] storage sidecar returned ${ putRes . status } , skipping logo embed test ` ,
) ;
return ;
}
assert . ok (
putRes . ok ,
` signed PUT must succeed (got ${ putRes . status } ${ putRes . statusText } ) ` ,
) ;
2026-05-03 15:10:34 +00:00
// 3) Save logo on global row (try/finally resets it).
2026-05-03 14:59:23 +00:00
const setLogo = await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "global" ,
fontFamily : "system" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "#000000" ,
logoObjectPath : objectPath ,
} ,
) ;
assert . equal ( setLogo . status , 200 , "admin must be able to save the global logo" ) ;
try {
2026-05-03 15:10:34 +00:00
// 4) Render PDF (header draws unconditionally).
2026-05-03 14:59:23 +00:00
const pdfDate = "2099-05-04" ;
const res = await api (
adminCookie ,
"GET" ,
` /api/executive-meetings/pdf?date= ${ pdfDate } &lang=ar ` ,
) ;
assert . equal (
res . status ,
200 ,
"PDF render must succeed with a logo configured" ,
) ;
const buf = Buffer . from ( await res . arrayBuffer ( ) ) ;
const ascii = buf . toString ( "latin1" ) ;
2026-05-03 15:10:34 +00:00
// 5) Assert PDFKit emitted an Image XObject.
2026-05-03 14:59:23 +00:00
assert . match (
ascii ,
/\/Subtype\s*\/Image/ ,
"uploaded brand logo must be embedded as an /Image XObject in the PDF" ,
) ;
2026-05-03 15:10:34 +00:00
// /Width 1 confirms our 1x1 fixture was embedded.
2026-05-03 14:59:23 +00:00
assert . match (
ascii ,
/\/Width\s+1[^0-9]/ ,
"embedded image dictionary must reflect the 1x1 source PNG" ,
) ;
} finally {
await api (
adminCookie ,
"PUT" ,
"/api/executive-meetings/font-settings" ,
{
scope : "global" ,
fontFamily : "system" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "#000000" ,
logoObjectPath : null ,
} ,
) ;
}
} ) ;
2026-04-29 07:39:24 +00:00
test ( "Sanitization: rich text strips disallowed tags but keeps inline formatting" , async ( ) => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>' ;
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : dirty ,
titleEn : dirty ,
meetingDate : today ,
attendees : [
{ name : 'Tester <em style="color:blue">One</em><script>x</script>' ,
attendanceType : "internal" , sortOrder : 0 } ,
] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const day = await api ( adminCookie , "GET" ,
` /api/executive-meetings?date= ${ today } ` ) ;
const body = await day . json ( ) ;
const m = body . meetings . find ( ( x ) => x . id === meeting . id ) ;
assert . ok ( m , "meeting must come back" ) ;
// disallowed tags must be stripped
assert . ok ( ! /<script/i . test ( m . titleAr ) , "script must be stripped from titleAr" ) ;
assert . ok ( ! /<script/i . test ( m . titleEn ) , "script must be stripped from titleEn" ) ;
assert . ok ( ! /<img/i . test ( m . titleAr ) , "img must be stripped" ) ;
assert . ok ( ! /<a /i . test ( m . titleAr ) , "anchor must be stripped" ) ;
assert . ok ( ! /javascript:/i . test ( m . titleAr ) , "javascript: scheme must be gone" ) ;
// allowed inline formatting must be preserved
assert . ok ( /<strong[^>]*>World<\/strong>/i . test ( m . titleAr ) , "strong must survive" ) ;
assert . ok ( /color:\s*#ff0000/i . test ( m . titleAr ) , "inline color must survive" ) ;
// attendee names get the same treatment
const att = m . attendees . find ( ( a ) => / Tester / . test ( a . name ) ) ;
assert . ok ( att , "attendee must come back" ) ;
assert . ok ( ! /<script/i . test ( att . name ) , "script stripped from attendee name" ) ;
assert . ok ( /<em[^>]*>One<\/em>/i . test ( att . name ) , "em must survive in attendee" ) ;
} ) ;
2026-04-30 05:56:23 +00:00
test ( "Sanitization: attendee.title is stripped of HTML on every write path" , async ( ) => {
const malicious = '<script>alert(1)</script><b onclick="x">CFO</b>' ;
// POST creates the meeting with a malicious title on one attendee.
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "تنظيف العنوان" ,
titleEn : "Title sanitization" ,
meetingDate : today ,
attendees : [
{
name : "Attendee POST" ,
title : malicious ,
attendanceType : "internal" ,
sortOrder : 0 ,
} ,
] ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
created . meetingIds . push ( meeting . id ) ;
const postedTitle = meeting . attendees [ 0 ] . title ;
assert . ok ( postedTitle , "title must round-trip" ) ;
assert . ok ( ! /<script/i . test ( postedTitle ) , "POST: <script> stripped from title" ) ;
assert . ok ( ! /<b/i . test ( postedTitle ) , "POST: <b> stripped from title" ) ;
assert . ok ( ! /onclick/i . test ( postedTitle ) , "POST: onclick stripped from title" ) ;
assert . ok ( /CFO/ . test ( postedTitle ) , "POST: visible text preserved in title" ) ;
// PATCH replaces attendees with a different malicious title.
const patched = await api (
adminCookie ,
"PATCH" ,
` /api/executive-meetings/ ${ meeting . id } ` ,
{
attendees : [
{
name : "Attendee PATCH" ,
title : '<img src=x onerror=alert(1)>Director' ,
attendanceType : "internal" ,
sortOrder : 0 ,
} ,
] ,
} ,
) ;
assert . equal ( patched . status , 200 ) ;
const patchedBody = await patched . json ( ) ;
const patchedTitle = patchedBody . attendees [ 0 ] . title ;
assert . ok ( ! /<img/i . test ( patchedTitle ) , "PATCH: <img> stripped from title" ) ;
assert . ok ( ! /onerror/i . test ( patchedTitle ) , "PATCH: onerror stripped from title" ) ;
assert . ok ( /Director/ . test ( patchedTitle ) , "PATCH: visible text preserved" ) ;
// PUT /attendees replaces the list and must sanitize too.
const put = await api (
adminCookie ,
"PUT" ,
` /api/executive-meetings/ ${ meeting . id } /attendees ` ,
{
attendees : [
{
name : "Attendee PUT" ,
title : '<a href="javascript:bad()">Manager</a>' ,
attendanceType : "virtual" ,
sortOrder : 0 ,
} ,
] ,
} ,
) ;
assert . equal ( put . status , 200 ) ;
const putBody = await put . json ( ) ;
const putTitle = putBody . attendees [ 0 ] . title ;
assert . ok ( ! /<a/i . test ( putTitle ) , "PUT: <a> stripped from title" ) ;
assert . ok ( ! /javascript:/i . test ( putTitle ) , "PUT: javascript: scheme gone" ) ;
assert . ok ( /Manager/ . test ( putTitle ) , "PUT: visible text preserved" ) ;
// Duplicate path: copy of a meeting with a sanitized title must stay sanitized.
const dup = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ meeting . id } /duplicate ` ,
{ targetDate : today } ,
) ;
assert . equal ( dup . status , 201 ) ;
const dupBody = await dup . json ( ) ;
created . meetingIds . push ( dupBody . id ) ;
const dupTitle = dupBody . attendees [ 0 ] . title ;
assert . ok ( ! /<a/i . test ( dupTitle ) , "duplicate: title remains free of HTML tags" ) ;
assert . ok ( ! /<script/i . test ( dupTitle ) , "duplicate: no <script> in copied title" ) ;
assert . ok ( /Manager/ . test ( dupTitle ) , "duplicate: visible text preserved" ) ;
} ) ;
2026-04-29 08:10:13 +00:00
test ( "Sanitization: text-align on <p> survives a round-trip via PATCH/GET" , async ( ) => {
const m = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "محاذاة" , titleEn : "Align" , meetingDate : today ,
} ) ;
const M = await m . json ( ) ; created . meetingIds . push ( M . id ) ;
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>' ;
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ M . id } ` , { titleAr : html } ) ;
assert . equal ( patch . status , 200 ) ;
const day = await api ( adminCookie , "GET" ,
` /api/executive-meetings?date= ${ today } ` ) ;
const body = await day . json ( ) ;
const got = body . meetings . find ( ( x ) => x . id === M . id ) ;
assert . ok ( got , "meeting must come back" ) ;
assert . match ( got . titleAr , /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i ,
"right-aligned <p> survives sanitization" ) ;
assert . match ( got . titleAr , /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i ,
"center-aligned <p> survives sanitization" ) ;
} ) ;
2026-04-29 08:03:25 +00:00
test ( "Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times" , async ( ) => {
2026-05-11 18:49:10 +00:00
// Use a per-run unique far-future date so leftover meetings from a
// previously-failed run can't cause an `incomplete_day_reorder` 400
// (the route rejects payloads that omit any non-cancelled meeting on
// the day, and we don't try to clean a stale fixed date here).
const stamp = Date . now ( ) ;
const dd = String ( ( stamp % 28 ) + 1 ) . padStart ( 2 , "0" ) ;
const mm = String ( ( Math . floor ( stamp / 28 ) % 12 ) + 1 ) . padStart ( 2 , "0" ) ;
const reorderDate = ` 2099- ${ mm } - ${ dd } ` ;
// Best-effort cleanup of any prior rows on the chosen slot (cheap and
// keeps re-runs deterministic without trusting external state).
await pool . query (
` DELETE FROM executive_meetings WHERE meeting_date = $ 1 ` ,
[ reorderDate ] ,
) ;
2026-04-29 07:39:24 +00:00
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
2026-04-29 08:03:25 +00:00
titleAr : "أول" , titleEn : "First" , meetingDate : reorderDate ,
2026-04-29 07:39:24 +00:00
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
2026-04-29 08:03:25 +00:00
titleAr : "ثاني" , titleEn : "Second" , meetingDate : reorderDate ,
2026-04-29 07:39:24 +00:00
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
2026-04-29 08:03:25 +00:00
titleAr : "ثالث" , titleEn : "Third" , meetingDate : reorderDate ,
2026-04-29 07:39:24 +00:00
startTime : "11:00" , endTime : "11:30" ,
} ) ;
assert . equal ( a . status , 201 ) ;
assert . equal ( b . status , 201 ) ;
assert . equal ( c . status , 201 ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// reverse the order: C, B, A
const reorder = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
2026-04-29 08:03:25 +00:00
{ meetingDate : reorderDate , orderedIds : [ C . id , B . id , A . id ] } ) ;
2026-04-29 07:39:24 +00:00
assert . equal ( reorder . status , 200 ) ;
const day = await api ( adminCookie , "GET" ,
2026-04-29 08:03:25 +00:00
` /api/executive-meetings?date= ${ reorderDate } ` ) ;
2026-04-29 07:39:24 +00:00
const body = await day . json ( ) ;
const byId = new Map ( body . meetings . map ( ( m ) => [ m . id , m ] ) ) ;
const cAfter = byId . get ( C . id ) ;
const bAfter = byId . get ( B . id ) ;
const aAfter = byId . get ( A . id ) ;
2026-04-29 08:03:25 +00:00
assert . equal ( cAfter . dailyNumber , 1 ) ;
assert . equal ( bAfter . dailyNumber , 2 ) ;
assert . equal ( aAfter . dailyNumber , 3 ) ;
2026-04-29 07:53:51 +00:00
assert . equal ( cAfter . startTime . slice ( 0 , 5 ) , "09:00" ) ;
assert . equal ( bAfter . startTime . slice ( 0 , 5 ) , "10:00" ) ;
assert . equal ( aAfter . startTime . slice ( 0 , 5 ) , "11:00" ) ;
2026-04-29 08:12:51 +00:00
const auditRows = await pool . query (
` SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'executive_meeting.reorder'
AND entity_id = ANY( $ 1::int[])
ORDER BY id DESC LIMIT 1 ` ,
[ [ A . id , B . id , C . id ] ] ,
) ;
assert . equal ( auditRows . rowCount , 1 , "expected one reorder audit row" ) ;
assert . equal ( auditRows . rows [ 0 ] . entity _type , "meeting" ) ;
const newOrder = auditRows . rows [ 0 ] . new _value ? . order ;
assert . deepEqual ( newOrder , [ C . id , B . id , A . id ] ,
"audit new_value.order must echo the new ordering" ) ;
2026-04-29 07:53:51 +00:00
} ) ;
2026-04-29 08:23:16 +00:00
test ( "Reorder: rejects orderedIds containing duplicates with code duplicate_ids" , async ( ) => {
const reorderDate = "2050-04-04" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : reorderDate ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : reorderDate ,
} ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const r = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : reorderDate , orderedIds : [ A . id , A . id , B . id ] } ) ;
assert . equal ( r . status , 400 ) ;
const body = await r . json ( ) ;
assert . equal ( body . code , "duplicate_ids" ,
"duplicate ids must be reported with the duplicate_ids code" ) ;
} ) ;
2026-05-14 06:23:49 +00:00
// dragging a visible meeting from one row to another must not
2026-05-02 09:15:53 +00:00
// disturb cancelled rows on the same day. The schedule view hides
// cancelled meetings (#273), so dnd-kit can only ever drag visible
// rows; the reorder endpoint must accept an orderedIds payload that
// covers exactly the visible (non-cancelled) meetings, slot-swap among
// them, and leave cancelled rows' time slots and daily numbers
// completely untouched. Before the fix, cancelled meetings were
// included in the slot pool and silently stole chronologically-sorted
// slots from the visible list, which made dragging a meeting from top
// to bottom land it in a non-chronological position on screen.
test ( "Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings" , async ( ) => {
const reorderDate = "2050-05-21" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : reorderDate ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B (cancelled)" , meetingDate : reorderDate ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : reorderDate ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const d = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "د" , titleEn : "D" , meetingDate : reorderDate ,
startTime : "12:00" , endTime : "12:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
const D = await d . json ( ) ; created . meetingIds . push ( D . id ) ;
// Cancel B in-place so the visible list is [A, C, D] but the day
// still holds 4 rows. Capture B's pre-reorder slot to assert it
// stays put after the reorder.
await pool . query (
` UPDATE executive_meetings SET status = 'cancelled' WHERE id = $ 1 ` ,
[ B . id ] ,
) ;
const { rows : bBeforeRows } = await pool . query (
` SELECT daily_number, start_time, end_time, status
FROM executive_meetings WHERE id = $ 1 ` ,
[ B . id ] ,
) ;
const bBefore = bBeforeRows [ 0 ] ;
// User drags D (bottom of the visible list) up to the top, so the
// visible order becomes [D, A, C]. The orderedIds payload covers
// ONLY visible meetings — that is what the patched client sends.
const reorder = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : reorderDate , orderedIds : [ D . id , A . id , C . id ] } ) ;
assert . equal ( reorder . status , 200 ,
"subset reorder (cancelled excluded) must be accepted" ) ;
const day = await api ( adminCookie , "GET" ,
` /api/executive-meetings?date= ${ reorderDate } ` ) ;
const body = await day . json ( ) ;
const byId = new Map ( body . meetings . map ( ( m ) => [ m . id , m ] ) ) ;
// Visible meetings inherit each other's slots, sorted chronologically:
// D ← (09:00, daily_number from A's slot)
// A ← (11:00, daily_number from C's slot)
// C ← (12:00, daily_number from D's slot)
// After the swap the visible rows must read top-to-bottom in
// chronological start-time order.
const visible = body . meetings
. filter ( ( m ) => m . status !== "cancelled" )
. sort ( ( a , b ) => a . dailyNumber - b . dailyNumber ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ D . id , A . id , C . id ] ,
"visible rows must be in the dropped order [D, A, C]" ) ;
const starts = visible . map ( ( m ) => m . startTime . slice ( 0 , 5 ) ) ;
for ( let i = 1 ; i < starts . length ; i ++ ) {
assert . ok ( starts [ i - 1 ] <= starts [ i ] ,
` visible row ${ i - 1 } ( ${ starts [ i - 1 ] } ) must be <= row ${ i } ( ${ starts [ i ] } ) chronologically ` ) ;
}
// Concretely the slot-swap should produce 09:00, 11:00, 12:00 for
// [D, A, C] (B's 10:00 slot stays with B, untouched).
assert . equal ( visible [ 0 ] . startTime . slice ( 0 , 5 ) , "09:00" ) ;
assert . equal ( visible [ 1 ] . startTime . slice ( 0 , 5 ) , "11:00" ) ;
assert . equal ( visible [ 2 ] . startTime . slice ( 0 , 5 ) , "12:00" ) ;
// Cancelled B: time slot AND daily_number must be unchanged.
const bAfter = byId . get ( B . id ) ;
assert . equal ( bAfter . status , "cancelled" ) ;
assert . equal ( bAfter . startTime . slice ( 0 , 5 ) ,
String ( bBefore . start _time ) . slice ( 0 , 5 ) ,
"cancelled meeting startTime must not change" ) ;
assert . equal ( bAfter . endTime . slice ( 0 , 5 ) ,
String ( bBefore . end _time ) . slice ( 0 , 5 ) ,
"cancelled meeting endTime must not change" ) ;
assert . equal ( bAfter . dailyNumber , bBefore . daily _number ,
"cancelled meeting dailyNumber must not change" ) ;
} ) ;
test ( "Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder" , async ( ) => {
const reorderDate = "2050-05-22" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : reorderDate ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B (cancelled)" , meetingDate : reorderDate ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
await pool . query (
` UPDATE executive_meetings SET status = 'cancelled' WHERE id = $ 1 ` ,
[ B . id ] ,
) ;
const r = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : reorderDate , orderedIds : [ B . id , A . id ] } ) ;
assert . equal ( r . status , 400 ,
"orderedIds containing a cancelled meeting must be rejected" ) ;
const body = await r . json ( ) ;
assert . equal ( body . code , "cancelled_in_reorder" ,
"rejection must be reported with the cancelled_in_reorder code" ) ;
} ) ;
2026-05-02 09:19:34 +00:00
test ( "Reorder: handles a day with a null-startTime meeting deterministically" , async ( ) => {
// Per the slot-swap sort, rows with non-null startTime come first
// (chronological), and null-startTime rows come last. After a reorder
// of [null-row, A, B], the null row should land in the LAST slot
// (whatever its startTime/dailyNumber were originally), and A/B should
// get the first two chronological slots. This proves the slot-swap
// does not crash on nulls and produces a stable order.
const reorderDate = "2050-06-10" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : reorderDate ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : reorderDate ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const n = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ن" , titleEn : "Null-time" , meetingDate : reorderDate ,
// startTime/endTime intentionally omitted -> null in DB
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const N = await n . json ( ) ; created . meetingIds . push ( N . id ) ;
// Ask: put null-row first, then A, then B
const r = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : reorderDate , orderedIds : [ N . id , A . id , B . id ] } ) ;
assert . equal ( r . status , 200 , "reorder with a null-time row must succeed" ) ;
const after = await pool . query (
` SELECT id, daily_number, start_time, end_time
FROM executive_meetings
WHERE meeting_date = $ 1
ORDER BY daily_number ` ,
[ reorderDate ] ,
) ;
// Slots sorted by startTime (nulls last) were:
// slot[0] = (09:00, 09:30, dn=A) for A originally
// slot[1] = (10:00, 10:30, dn=B) for B originally
// slot[2] = (null, null, dn=N) for N originally
// Assignment N->slot[0], A->slot[1], B->slot[2]:
const byId = Object . fromEntries ( after . rows . map ( ( r ) => [ r . id , r ] ) ) ;
assert . equal ( byId [ N . id ] . start _time , "09:00:00" ,
"null-time row should inherit the first chronological slot's startTime" ) ;
assert . equal ( byId [ A . id ] . start _time , "10:00:00" ,
"A should inherit the second chronological slot's startTime" ) ;
assert . equal ( byId [ B . id ] . start _time , null ,
"B should inherit the originally-null third slot's startTime" ) ;
// dailyNumbers must be unique 1..3 across the day
const dns = after . rows . map ( ( r ) => r . daily _number ) . sort ( ) ;
assert . deepEqual ( dns , [ 1 , 2 , 3 ] . sort ( ) ,
"dailyNumbers across the day must remain a unique 1..N permutation" ) ;
} ) ;
2026-04-29 08:03:25 +00:00
test ( "Reorder: rejects an incomplete-day request (orderedIds missing some)" , async ( ) => {
const reorderDate = "2050-02-20" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : reorderDate ,
startTime : "08:00" , endTime : "08:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : reorderDate ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
// Send only one of the two — server must refuse so 1..N renumber stays safe.
const r = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : reorderDate , orderedIds : [ A . id ] } ) ;
assert . equal ( r . status , 400 ) ;
const body = await r . json ( ) ;
assert . equal ( body . code , "incomplete_day" ) ;
} ) ;
2026-04-29 07:39:24 +00:00
test ( "Reorder: rejects ids that do not all belong to meetingDate" , async ( ) => {
const sameDay = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اليوم" , titleEn : "Today" , meetingDate : today ,
} ) ;
const other = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "غدًا" , titleEn : "Tomorrow" ,
meetingDate : new Date ( Date . now ( ) + 86400000 )
. toISOString ( ) . slice ( 0 , 10 ) ,
} ) ;
const S = await sameDay . json ( ) ; created . meetingIds . push ( S . id ) ;
const O = await other . json ( ) ; created . meetingIds . push ( O . id ) ;
const r = await api ( adminCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : today , orderedIds : [ S . id , O . id ] } ) ;
assert . equal ( r . status , 400 ) ;
} ) ;
2026-05-02 09:47:12 +00:00
// ---------------------------------------------------------------------
2026-05-14 06:23:49 +00:00
// Auto-sort the day chronologically on every write that
2026-05-02 09:47:12 +00:00
// affects time/order. The user reported a row showing 13:00 sitting
// above a 12:00 row after editing a single startTime — that bug exists
// because the inline-edit PATCH never re-derived `daily_number`. These
// tests lock in the new contract: any time a write touches startTime,
// endTime, meetingDate, status, or creates/duplicates/deletes a row,
// the day is renumbered 1..N by start_time so the visible list is
// strictly chronological. Cancelled rows go to the tail (preserving
// the existing renumberDayByStartTime convention) so the per-day
// unique index on (meeting_date, daily_number) stays satisfied.
// ---------------------------------------------------------------------
// Helper: assert that the visible (non-cancelled) rows on `date` read
// 1..N by start_time order, with no chronological inversions and no
// duplicate dailyNumbers. Returns the visible rows for further checks.
async function assertDayChronological ( date ) {
const day = await api ( adminCookie , "GET" ,
` /api/executive-meetings?date= ${ date } ` ) ;
assert . equal ( day . status , 200 ) ;
const body = await day . json ( ) ;
const visible = body . meetings
. filter ( ( m ) => m . status !== "cancelled" )
. sort ( ( a , b ) => a . dailyNumber - b . dailyNumber ) ;
// dailyNumbers must be 1..N with no gaps.
for ( let i = 0 ; i < visible . length ; i ++ ) {
assert . equal ( visible [ i ] . dailyNumber , i + 1 ,
` visible row index ${ i } on ${ date } must have dailyNumber ${ i + 1 } , got ${ visible [ i ] . dailyNumber } ` ) ;
}
// start_times must be non-decreasing top-to-bottom (nulls last per
// renumberDayByStartTime's NULLS LAST clause).
let lastTime = null ;
let seenNull = false ;
for ( const m of visible ) {
if ( m . startTime == null ) {
seenNull = true ;
continue ;
}
assert . ok ( ! seenNull ,
` null-startTime row on ${ date } must come after timed rows, but a timed row ( ${ m . startTime } ) followed a null one ` ) ;
const t = String ( m . startTime ) . slice ( 0 , 8 ) ;
if ( lastTime != null ) {
assert . ok ( lastTime <= t ,
` start_time inversion on ${ date } : ${ lastTime } (row N) > ${ t } (row N+1) ` ) ;
}
lastTime = t ;
}
// No duplicate dailyNumbers across the whole day (cancelled + visible).
const seen = new Set ( ) ;
for ( const m of body . meetings ) {
assert . ok ( ! seen . has ( m . dailyNumber ) ,
` duplicate dailyNumber ${ m . dailyNumber } on ${ date } after auto-sort ` ) ;
seen . add ( m . dailyNumber ) ;
}
return visible ;
}
test ( "Auto-sort: PATCH startTime later than next row demotes it to its new chronological slot" , async ( ) => {
const d = "2050-06-01" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
await assertDayChronological ( d ) ;
// The exact scenario from the user's screenshot: A is at the top
// (09:00). User edits A's startTime to 13:00 — without auto-sort, A
// stays in row 1 even though it now has the latest time.
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ A . id } ` ,
{ startTime : "13:00" , endTime : "13:30" } ) ;
assert . equal ( patch . status , 200 ) ;
const visible = await assertDayChronological ( d ) ;
// After auto-sort the order must be B, C, A — A demoted to the tail
// because its 13:00 is now the latest start time on the day.
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ B . id , C . id , A . id ] ,
"the row whose startTime was bumped to 13:00 must move to the bottom" ) ;
} ) ;
test ( "Auto-sort: PATCH startTime earlier than first row promotes it to the top" , async ( ) => {
const d = "2050-06-02" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// Move C (currently bottom @ 11:00) to 07:00 — must jump to the top.
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ C . id } ` ,
{ startTime : "07:00" , endTime : "07:30" } ) ;
assert . equal ( patch . status , 200 ) ;
const visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ C . id , A . id , B . id ] ,
"the row whose startTime was moved earliest must rise to the top" ) ;
} ) ;
test ( "Auto-sort: POST create with a middle startTime slots the new row between existing ones" , async ( ) => {
const d = "2050-06-03" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// Create B at 10:00 — without auto-sort it would land at the tail
// (nextDailyNumber = 3) so the visible order would be [A, C, B] even
// though chronologically it belongs in the middle.
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
assert . equal ( b . status , 201 ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ A . id , B . id , C . id ] ,
"newly-created meeting with middle startTime must slot between existing rows" ) ;
} ) ;
test ( "Auto-sort: clearing startTime to null pushes the row to the tail of the visible list" , async ( ) => {
const d = "2050-06-04" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// Clear B's start/end times — renumberDayByStartTime puts NULL-time
// rows at the tail of the visible 1..N (NULLS LAST).
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ B . id } ` ,
{ startTime : null , endTime : null } ) ;
assert . equal ( patch . status , 200 ) ;
const visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ A . id , C . id , B . id ] ,
"row with cleared startTime must sink below the timed rows" ) ;
assert . equal ( visible [ 2 ] . startTime , null , "B's startTime must be null after clearing" ) ;
} ) ;
test ( "Auto-sort: cancel pushes a row out of the visible list and renumbers, uncancel re-slots chronologically" , async ( ) => {
const d = "2050-06-05" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// Cancel B via the dedicated endpoint (which already calls
// renumberDayByStartTime). The visible list shrinks to [A, C] and
// their dailyNumbers must compact to 1..2 with B sitting at the tail.
const cancel = await api ( adminCookie , "POST" ,
` /api/executive-meetings/ ${ B . id } /cancel ` ) ;
assert . equal ( cancel . status , 200 ) ;
let visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ A . id , C . id ] ,
"cancelled row must drop out of the visible list" ) ;
// Uncancel via PATCH status='scheduled'. B must re-enter the visible
// list at its chronological slot (between A @ 09:00 and C @ 11:00).
const uncancel = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ B . id } ` ,
{ status : "scheduled" } ) ;
assert . equal ( uncancel . status , 200 ) ;
visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ A . id , B . id , C . id ] ,
"uncancelled row must re-slot chronologically by its startTime" ) ;
} ) ;
test ( "Auto-sort: PATCH meetingDate cross-day move renumbers BOTH days chronologically" , async ( ) => {
const dSrc = "2050-07-10" ;
const dDst = "2050-07-11" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A-src" , meetingDate : dSrc ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B-src" , meetingDate : dSrc ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C-src" , meetingDate : dSrc ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const x = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "س" , titleEn : "X-dst" , meetingDate : dDst ,
startTime : "08:00" , endTime : "08:30" ,
} ) ;
const y = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ص" , titleEn : "Y-dst" , meetingDate : dDst ,
startTime : "13:00" , endTime : "13:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
const X = await x . json ( ) ; created . meetingIds . push ( X . id ) ;
const Y = await y . json ( ) ; created . meetingIds . push ( Y . id ) ;
// Move B from the source day to the destination day at 10:00 — it
// must arrive between X (08:00) and Y (13:00) on dDst, and the
// source day must close ranks so [A, C] reads 1..2.
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ B . id } ` ,
{ meetingDate : dDst } ) ;
assert . equal ( patch . status , 200 ) ;
const srcVisible = await assertDayChronological ( dSrc ) ;
assert . deepEqual ( srcVisible . map ( ( m ) => m . id ) , [ A . id , C . id ] ,
"source day must compact to [A, C] after B moves away" ) ;
const dstVisible = await assertDayChronological ( dDst ) ;
assert . deepEqual ( dstVisible . map ( ( m ) => m . id ) , [ X . id , B . id , Y . id ] ,
"destination day must accept B at its chronological slot between X and Y" ) ;
} ) ;
test ( "Auto-sort: DELETE leaves the day's `#` sequence gap-free and chronological" , async ( ) => {
const d = "2050-06-15" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// Delete the middle row. Without auto-sort, dailyNumbers would be
// [1, 3] with a gap, and a future POST would reuse dailyNumber=2.
const del = await api ( adminCookie , "DELETE" ,
` /api/executive-meetings/ ${ B . id } ` ) ;
assert . ok ( del . status === 200 || del . status === 204 ) ;
// Drop B from cleanup since it's already gone.
created . meetingIds = created . meetingIds . filter ( ( x ) => x !== B . id ) ;
const visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ A . id , C . id ] ,
"remaining rows must compact to 1..2 with no gap after delete" ) ;
} ) ;
test ( "Auto-sort: POST /duplicate slots the cloned meeting at its chronological position on the target day" , async ( ) => {
const dSrc = "2050-06-20" ;
const dDst = "2050-06-21" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A-src" , meetingDate : dSrc ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const x = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "س" , titleEn : "X-dst" , meetingDate : dDst ,
startTime : "08:00" , endTime : "08:30" ,
} ) ;
const y = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ص" , titleEn : "Y-dst" , meetingDate : dDst ,
startTime : "13:00" , endTime : "13:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const X = await x . json ( ) ; created . meetingIds . push ( X . id ) ;
const Y = await y . json ( ) ; created . meetingIds . push ( Y . id ) ;
// Duplicate A (10:00) onto dDst — the clone must land between X
// (08:00) and Y (13:00). Without auto-sort, the duplicate would be
// appended at the tail (dailyNumber = 3) and the visible order would
// be [X, Y, clone] even though clone @ 10:00 belongs in the middle.
const dup = await api ( adminCookie , "POST" ,
` /api/executive-meetings/ ${ A . id } /duplicate ` ,
{ targetDate : dDst } ) ;
assert . equal ( dup . status , 201 ) ;
const Clone = await dup . json ( ) ; created . meetingIds . push ( Clone . id ) ;
const visible = await assertDayChronological ( dDst ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ X . id , Clone . id , Y . id ] ,
"duplicate must slot at its chronological position on the target day" ) ;
} ) ;
2026-05-02 09:54:08 +00:00
// #312: when a PATCH (e.g. typing 13:00 on the row currently sitting
// above a 12:00 row) triggers auto-sort and the row visibly moves to a
// new dailyNumber, the audit row for that PATCH must record both the
// post-renumber dailyNumber and an `orderShifted: true` flag with the
// before/after visible-row order. Without this an admin reading the
// audit cannot tell that the user's startTime edit also moved the row
// from `#1` to `#3`.
test ( "Auto-sort: PATCH that reorders the day records orderShifted + final dailyNumber + dayOrder in the audit row" , async ( ) => {
const d = "2050-07-12" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const b = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ب" , titleEn : "B" , meetingDate : d ,
startTime : "10:00" , endTime : "10:30" ,
} ) ;
const c = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ج" , titleEn : "C" , meetingDate : d ,
startTime : "11:00" , endTime : "11:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const B = await b . json ( ) ; created . meetingIds . push ( B . id ) ;
const C = await c . json ( ) ; created . meetingIds . push ( C . id ) ;
// Sanity: starting visible order is A(09), B(10), C(11) at #1,#2,#3.
let visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ A . id , B . id , C . id ] ) ;
// Move A from 09:00 -> 13:00 (the user's exact bug scenario). After
// auto-sort the visible order should become B(10), C(11), A(13) and
// A's dailyNumber should be 3.
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ A . id } ` ,
{ startTime : "13:00" , endTime : "13:30" } ) ;
assert . equal ( patch . status , 200 ) ;
visible = await assertDayChronological ( d ) ;
assert . deepEqual ( visible . map ( ( m ) => m . id ) , [ B . id , C . id , A . id ] ) ;
// Read the audit row for THIS PATCH and assert the shift metadata.
const auditRows = await pool . query (
` SELECT action, entity_type, entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'update'
AND entity_type = 'meeting'
AND entity_id = $ 1
ORDER BY id DESC LIMIT 1 ` ,
[ A . id ] ,
) ;
assert . equal ( auditRows . rowCount , 1 , "expected one update audit row for A" ) ;
const newValue = auditRows . rows [ 0 ] . new _value ;
assert . ok ( newValue , "audit new_value must be present" ) ;
assert . equal ( newValue . orderShifted , true ,
"audit must flag orderShifted=true when auto-sort moved the row" ) ;
assert . equal ( newValue . dailyNumber , 3 ,
"audit's newValue.dailyNumber must echo the post-renumber position (#3), not the pre-renumber #1" ) ;
assert . ok ( Array . isArray ( newValue . dayOrder ) ,
"audit must include dayOrder array describing the visible-row shift" ) ;
const shift = newValue . dayOrder . find ( ( s ) => s . date === d ) ;
assert . ok ( shift , ` dayOrder must include an entry for the affected date ${ d } ` ) ;
assert . deepEqual ( shift . before , [ A . id , B . id , C . id ] ,
"dayOrder.before must be the visible row IDs in pre-renumber dailyNumber order" ) ;
assert . deepEqual ( shift . after , [ B . id , C . id , A . id ] ,
"dayOrder.after must be the visible row IDs in post-renumber dailyNumber order" ) ;
} ) ;
// #312: a non-order-affecting PATCH (e.g. just changing the title)
// must NOT add orderShifted/dayOrder to the audit row, since auto-sort
// did not run. This guards against accidentally treating every PATCH
// as a reorder in the audit UI.
test ( "Auto-sort: PATCH that does not touch time/date/status leaves orderShifted absent from the audit" , async ( ) => {
const d = "2050-07-13" ;
const a = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "أ" , titleEn : "A" , meetingDate : d ,
startTime : "09:00" , endTime : "09:30" ,
} ) ;
const A = await a . json ( ) ; created . meetingIds . push ( A . id ) ;
const patch = await api ( adminCookie , "PATCH" ,
` /api/executive-meetings/ ${ A . id } ` ,
{ titleEn : "A renamed" } ) ;
assert . equal ( patch . status , 200 ) ;
const auditRows = await pool . query (
` SELECT new_value FROM executive_meeting_audit_logs
WHERE action = 'update' AND entity_type = 'meeting'
AND entity_id = $ 1
ORDER BY id DESC LIMIT 1 ` ,
[ A . id ] ,
) ;
assert . equal ( auditRows . rowCount , 1 ) ;
const newValue = auditRows . rows [ 0 ] . new _value ;
assert . ok ( ! ( "orderShifted" in newValue ) ,
"title-only PATCH must not add orderShifted to the audit" ) ;
assert . ok ( ! ( "dayOrder" in newValue ) ,
"title-only PATCH must not add dayOrder to the audit" ) ;
} ) ;
2026-04-29 07:39:24 +00:00
test ( "Reorder: user without executive role is denied (403)" , async ( ) => {
const m = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ا " , titleEn : "A" , meetingDate : today ,
} ) ;
const M = await m . json ( ) ; created . meetingIds . push ( M . id ) ;
// a brand-new "user" has only the base 'user' role (no executive perms)
const plain = await createUser ( "em_plain" , "user" ) ;
const plainCookie = await login ( plain . username , TEST _PASSWORD ) ;
const r = await api ( plainCookie , "POST" ,
"/api/executive-meetings/reorder" ,
{ meetingDate : today , orderedIds : [ M . id ] } ) ;
assert . equal ( r . status , 403 ) ;
} ) ;
2026-04-29 18:27:15 +00:00
// ---------------------------------------------------------------------
2026-05-14 06:23:49 +00:00
// Phase-2 additions:
2026-04-29 18:27:15 +00:00
// - per-role meeting CRUD permissions
// - request reject / withdraw flows
// - task assignee-only status updates
// - font-settings PATCH/GET roundtrip
// - non-numeric :id returns 404 (router.param guard) instead of crashing
// - audit-insert failure rolls back the parent mutation (transactional)
// ---------------------------------------------------------------------
test ( "Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allowed" , async ( ) => {
// executive_coordinator is in REQUEST_ROLES but NOT in MUTATE_ROLES, so
// a direct POST/PATCH/DELETE on /executive-meetings must come back 403.
const coordCreate = await api ( coordCookie , "POST" , "/api/executive-meetings" , {
titleAr : "م" , titleEn : "M" , meetingDate : today ,
} ) ;
assert . equal ( coordCreate . status , 403 ,
"executive_coordinator must not be able to POST a meeting" ) ;
// executive_coord_lead is in MUTATE_ROLES, so the same call must succeed.
const leadCreate = await api ( leadCookie , "POST" , "/api/executive-meetings" , {
titleAr : "ل" , titleEn : "L" , meetingDate : today ,
} ) ;
assert . equal ( leadCreate . status , 201 ,
"executive_coord_lead must be able to POST a meeting" ) ;
const leadMeeting = await leadCreate . json ( ) ;
created . meetingIds . push ( leadMeeting . id ) ;
// Coordinator can still GET (read role) but cannot PATCH or DELETE.
const coordRead = await api ( coordCookie , "GET" ,
` /api/executive-meetings/ ${ leadMeeting . id } ` ) ;
assert . equal ( coordRead . status , 200 ,
"coordinator should still be allowed to read meeting details" ) ;
const coordPatch = await api ( coordCookie , "PATCH" ,
` /api/executive-meetings/ ${ leadMeeting . id } ` , { notes : "no" } ) ;
assert . equal ( coordPatch . status , 403 ,
"coordinator must not PATCH meetings directly" ) ;
const coordDelete = await api ( coordCookie , "DELETE" ,
` /api/executive-meetings/ ${ leadMeeting . id } ` ) ;
assert . equal ( coordDelete . status , 403 ,
"coordinator must not DELETE meetings directly" ) ;
// Lead can mutate.
const leadPatch = await api ( leadCookie , "PATCH" ,
` /api/executive-meetings/ ${ leadMeeting . id } ` , { notes : "ok" } ) ;
assert . equal ( leadPatch . status , 200 ,
"executive_coord_lead must be able to PATCH meetings" ) ;
} ) ;
test ( "Font settings: PUT then GET returns the user-scoped row roundtrip" , async ( ) => {
const put = await api ( adminCookie , "PUT" ,
"/api/executive-meetings/font-settings" , {
scope : "user" ,
fontFamily : "Tajawal" ,
fontSize : 18 ,
fontWeight : "bold" ,
alignment : "center" ,
2026-05-03 14:34:29 +00:00
fontColor : "#1f2937" ,
2026-04-29 18:27:15 +00:00
} ) ;
assert . equal ( put . status , 200 ) ;
const get = await api ( adminCookie , "GET" ,
"/api/executive-meetings/font-settings" ) ;
assert . equal ( get . status , 200 ) ;
const body = await get . json ( ) ;
assert . ok ( body . user , "GET must include the user-scoped row" ) ;
assert . equal ( body . user . scope , "user" ) ;
assert . equal ( body . user . fontFamily , "Tajawal" ) ;
assert . equal ( body . user . fontSize , 18 ) ;
assert . equal ( body . user . fontWeight , "bold" ) ;
assert . equal ( body . user . alignment , "center" ) ;
2026-05-03 14:34:29 +00:00
assert . equal ( body . user . fontColor , "#1f2937" ,
"fontColor must persist via the user-scope row" ) ;
2026-04-29 18:27:15 +00:00
// PATCH alias must hit the same upsert handler.
const patch = await api ( adminCookie , "PATCH" ,
"/api/executive-meetings/font-settings" , {
scope : "user" ,
2026-05-01 19:55:39 +00:00
fontFamily : "DIN Next LT Arabic" ,
2026-04-29 18:27:15 +00:00
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
2026-05-03 14:34:29 +00:00
fontColor : "#000000" ,
2026-04-29 18:27:15 +00:00
} ) ;
assert . equal ( patch . status , 200 ) ;
const get2 = await api ( adminCookie , "GET" ,
"/api/executive-meetings/font-settings" ) ;
const body2 = await get2 . json ( ) ;
2026-05-11 18:49:10 +00:00
// The upsert handler stores `null` on the user row for any field
// whose value matches the current global default — see the
// "nullified" overlay block in the route. The client resolves the
// effective value at read time as `user[field] ?? global[field]`.
// So for fields that DO differ from the global default we keep the
// user value; for fields that MATCH the global default we expect
// null on the user row.
const effective = ( field ) => body2 . user [ field ] ? ? body2 . global ? . [ field ] ? ? null ;
assert . equal ( effective ( "fontFamily" ) , "DIN Next LT Arabic" ) ;
assert . equal ( effective ( "fontSize" ) , 14 ) ;
assert . equal ( effective ( "fontWeight" ) , "regular" ) ;
assert . equal ( effective ( "alignment" ) , "start" ) ;
assert . equal ( effective ( "fontColor" ) , "#000000" ) ;
2026-05-03 14:34:29 +00:00
} ) ;
test ( "Font settings: rejects malformed fontColor and logoObjectPath" , async ( ) => {
// The hex regex on the API must reject non-#RRGGBB inputs so the
// value can be safely round-tripped through CSS and PDFKit.
const badColor = await api ( adminCookie , "PATCH" ,
"/api/executive-meetings/font-settings" , {
scope : "user" ,
fontFamily : "system" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "red" ,
} ) ;
assert . equal ( badColor . status , 400 , "non-hex fontColor must 400" ) ;
// Anything that isn't a /objects/<id> path should also be rejected
// — we don't want callers smuggling http URLs or relative paths
// into the brand logo slot.
const badPath = await api ( adminCookie , "PATCH" ,
"/api/executive-meetings/font-settings" , {
scope : "global" ,
fontFamily : "system" ,
fontSize : 14 ,
fontWeight : "regular" ,
alignment : "start" ,
fontColor : "#000000" ,
logoObjectPath : "https://evil.example.com/logo.png" ,
} ) ;
assert . equal ( badPath . status , 400 , "non-/objects path must 400" ) ;
2026-04-29 18:27:15 +00:00
} ) ;
test ( "router.param: non-numeric :id returns 404 across endpoints (no crash)" , async ( ) => {
// The router.param("id", ...) guard in executive-meetings.ts must reject
// non-digit ids by calling next("route"), so Express falls through to 404
// instead of attempting Number("abc") and crashing further down.
const cases = [
[ "GET" , "/api/executive-meetings/abc" ] ,
[ "PATCH" , "/api/executive-meetings/abc" ] ,
[ "DELETE" , "/api/executive-meetings/abc" ] ,
[ "GET" , "/api/executive-meetings/123abc" ] ,
[ "GET" , "/api/executive-meetings/-1" ] ,
[ "POST" , "/api/executive-meetings/abc/duplicate" ] ,
[ "PUT" , "/api/executive-meetings/abc/attendees" ] ,
] ;
for ( const [ method , path ] of cases ) {
const res = await api ( adminCookie , method , path ,
method === "GET" || method === "DELETE" ? undefined : { } ) ;
assert . equal ( res . status , 404 ,
` ${ method } ${ path } should 404 (got ${ res . status } ) ` ) ;
// Express's default 404 fallthrough returns an HTML "Cannot METHOD
// /path" page (text/html), so we assert that the body is *not* the
// pretty-printed JSON of an unhandled exception. Whichever Express
// produces, it must not be a 5xx HTML error stack trace.
const body = await res . text ( ) ;
assert . ok ( ! /<pre>Error:/i . test ( body ) ,
` ${ method } ${ path } body must not contain a thrown Error stack ` ) ;
assert . ok ( ! /TypeError|ReferenceError/i . test ( body ) ,
` ${ method } ${ path } body must not contain a JS exception name ` ) ;
}
} ) ;
test ( "Transactional safety: a failing audit insert rolls back the parent DELETE" , async ( ) => {
// Create a meeting we can target.
const create = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "اختبار-صفقة" , titleEn : "Tx Rollback Target" , meetingDate : today ,
} ) ;
assert . equal ( create . status , 201 ) ;
const meeting = await create . json ( ) ;
// Do NOT push to created.meetingIds yet — we want to verify the row is
// still there after the deliberately-failing DELETE, then clean it up
// ourselves.
const meetingId = meeting . id ;
// Install a BEFORE INSERT trigger on the audit log table that raises an
// exception only when our specific (entity_type='meeting', action='delete',
// entity_id=<this meeting>) row is being inserted. Other audit inserts —
// including the ones that other tests run in parallel/sequence — are
// unaffected.
const triggerName = ` tx_rollback_test_ ${ meetingId } ` ;
const fnName = ` tx_rollback_test_fn_ ${ meetingId } ` ;
await pool . query ( `
CREATE OR REPLACE FUNCTION ${ fnName } () RETURNS trigger AS $ $
BEGIN
IF NEW.entity_type = 'meeting'
AND NEW.action = 'delete'
AND NEW.entity_id = ${ meetingId } THEN
RAISE EXCEPTION 'forced audit failure for tx rollback test';
END IF;
RETURN NEW;
END;
$ $ LANGUAGE plpgsql;
` ) ;
await pool . query ( `
CREATE TRIGGER ${ triggerName }
BEFORE INSERT ON executive_meeting_audit_logs
FOR EACH ROW EXECUTE FUNCTION ${ fnName } ();
` ) ;
let deleteStatus = null ;
try {
const del = await api ( adminCookie , "DELETE" ,
` /api/executive-meetings/ ${ meetingId } ` ) ;
deleteStatus = del . status ;
} finally {
// Always tear the trigger + function down so the rest of the test file
// (and any other test runs) are not poisoned.
await pool . query ( ` DROP TRIGGER IF EXISTS ${ triggerName } ON executive_meeting_audit_logs ` ) ;
await pool . query ( ` DROP FUNCTION IF EXISTS ${ fnName } () ` ) ;
}
assert . equal ( deleteStatus , 500 ,
"DELETE must surface a 500 when the audit insert raises" ) ;
// The meeting row must still exist — the surrounding db.transaction()
// should have rolled the DELETE back when the audit insert failed.
const stillThere = await pool . query (
` SELECT id FROM executive_meetings WHERE id = $ 1 ` ,
[ meetingId ] ,
) ;
assert . equal ( stillThere . rowCount , 1 ,
"parent DELETE must be rolled back when audit insert fails" ) ;
// Now clean up for real — register the id so afterAll deletes it.
created . meetingIds . push ( meetingId ) ;
} ) ;
2026-04-30 18:56:05 +00:00
test ( "Notification prefs: GET defaults every event type to in-app + email on" , async ( ) => {
// Use a brand-new coordinator so we know there are no pre-existing rows.
const fresh = await createUser ( "em_pref_a" , "executive_coordinator" ) ;
const cookie = await login ( fresh . username , TEST _PASSWORD ) ;
const res = await api ( cookie , "GET" , "/api/executive-meetings/notification-prefs" ) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . ok ( Array . isArray ( body . types ) && body . types . length > 0 ,
"response must include the canonical types list" ) ;
assert . ok ( Array . isArray ( body . prefs ) && body . prefs . length === body . types . length ,
"response must return one merged pref per canonical type" ) ;
for ( const p of body . prefs ) {
assert . equal ( p . inApp , true , ` default inApp must be true for ${ p . notificationType } ` ) ;
assert . equal ( p . email , true , ` default email must be true for ${ p . notificationType } ` ) ;
}
} ) ;
test ( "Notification prefs: PUT upserts and is reflected in subsequent GET" , async ( ) => {
const fresh = await createUser ( "em_pref_b" , "executive_coordinator" ) ;
const cookie = await login ( fresh . username , TEST _PASSWORD ) ;
// Pick a known type from the canonical list returned by GET.
const initial = await ( await api ( cookie , "GET" ,
"/api/executive-meetings/notification-prefs" ) ) . json ( ) ;
const targetType = initial . types [ 0 ] ;
const put = await api ( cookie , "PUT" ,
"/api/executive-meetings/notification-prefs" ,
{ prefs : [ { notificationType : targetType , inApp : false , email : true } ] } ) ;
assert . equal ( put . status , 200 ) ;
const putBody = await put . json ( ) ;
assert . equal ( putBody . ok , true ) ;
assert . equal ( putBody . count , 1 ) ;
const after = await ( await api ( cookie , "GET" ,
"/api/executive-meetings/notification-prefs" ) ) . json ( ) ;
const row = after . prefs . find ( ( p ) => p . notificationType === targetType ) ;
assert . ok ( row , "updated pref must come back in GET" ) ;
assert . equal ( row . inApp , false ) ;
assert . equal ( row . email , true ) ;
// Other types must remain at default (true / true).
for ( const p of after . prefs ) {
if ( p . notificationType === targetType ) continue ;
assert . equal ( p . inApp , true , ` ${ p . notificationType } should remain default-on ` ) ;
assert . equal ( p . email , true , ` ${ p . notificationType } should remain default-on ` ) ;
}
// Re-PUT the same type should overwrite (upsert), not duplicate.
const put2 = await api ( cookie , "PUT" ,
"/api/executive-meetings/notification-prefs" ,
{ prefs : [ { notificationType : targetType , inApp : true , email : false } ] } ) ;
assert . equal ( put2 . status , 200 ) ;
const after2 = await ( await api ( cookie , "GET" ,
"/api/executive-meetings/notification-prefs" ) ) . json ( ) ;
const row2 = after2 . prefs . find ( ( p ) => p . notificationType === targetType ) ;
assert . equal ( row2 . inApp , true ) ;
assert . equal ( row2 . email , false ) ;
} ) ;
test ( "Notification prefs: PUT rejects unknown notificationType with 400" , async ( ) => {
const fresh = await createUser ( "em_pref_c" , "executive_coordinator" ) ;
const cookie = await login ( fresh . username , TEST _PASSWORD ) ;
const bad = await api ( cookie , "PUT" ,
"/api/executive-meetings/notification-prefs" ,
{ prefs : [ { notificationType : "totally_made_up" , inApp : false , email : false } ] } ) ;
assert . equal ( bad . status , 400 ) ;
} ) ;
2026-05-01 07:30:00 +00:00
test ( "Notification prefs: DELETE wipes the user's pref rows so GET reverts to defaults" , async ( ) => {
// #236: clicking "Restore defaults" in the UI hits DELETE. After it
// returns, every event type must read default-on regardless of what
// had been muted before, and a follow-up fan-out must reach the user
// again. Use a fresh approver so we don't disturb other prefs tests.
const fresh = await createUser ( "em_pref_restore" , "executive_office_manager" ) ;
const cookie = await login ( fresh . username , TEST _PASSWORD ) ;
const put = await api ( cookie , "PUT" ,
"/api/executive-meetings/notification-prefs" ,
{
prefs : [
2026-05-01 08:18:29 +00:00
{ notificationType : "meeting_created" , inApp : false , email : false } ,
2026-05-01 07:30:00 +00:00
] ,
} ) ;
assert . equal ( put . status , 200 ) ;
const { rows : pre } = await pool . query (
` SELECT COUNT(*)::int AS n
FROM executive_meeting_notification_prefs
WHERE user_id = $ 1 ` ,
[ fresh . id ] ,
) ;
2026-05-01 08:18:29 +00:00
assert . equal ( pre [ 0 ] . n , 1 , "PUT must have left 1 pref row" ) ;
2026-05-01 07:30:00 +00:00
const del = await api ( cookie , "DELETE" ,
"/api/executive-meetings/notification-prefs" ) ;
assert . equal ( del . status , 200 ) ;
const delBody = await del . json ( ) ;
assert . equal ( delBody . ok , true ) ;
2026-05-01 08:18:29 +00:00
assert . equal ( delBody . count , 1 , "DELETE must report wiping the row" ) ;
2026-05-01 07:30:00 +00:00
const { rows : post } = await pool . query (
` SELECT COUNT(*)::int AS n
FROM executive_meeting_notification_prefs
WHERE user_id = $ 1 ` ,
[ fresh . id ] ,
) ;
assert . equal ( post [ 0 ] . n , 0 , "no pref rows must remain after DELETE" ) ;
// GET must now synthesize default-on for every type.
const after = await ( await api ( cookie , "GET" ,
"/api/executive-meetings/notification-prefs" ) ) . json ( ) ;
for ( const p of after . prefs ) {
assert . equal ( p . inApp , true , ` ${ p . notificationType } must be default-on after DELETE ` ) ;
assert . equal ( p . email , true , ` ${ p . notificationType } must be default-on after DELETE ` ) ;
}
// And actual fan-out must reach the user again — a regression in the
// DELETE handler that left rows behind would silently re-mute them.
const beforeCount = await pool . query (
` SELECT COUNT(*)::int AS n
FROM executive_meeting_notifications
WHERE user_id = $ 1
2026-05-01 08:18:29 +00:00
AND notification_type = 'meeting_created' ` ,
2026-05-01 07:30:00 +00:00
[ fresh . id ] ,
) ;
2026-05-01 08:18:29 +00:00
const submit = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : "إخطار-إعادة-الافتراضات" ,
titleEn : "Restore-defaults fan-out" ,
meetingDate : today ,
attendees : [ ] ,
} ) ;
2026-05-01 07:30:00 +00:00
assert . equal ( submit . status , 201 ) ;
2026-05-01 08:18:29 +00:00
const submittedMeeting = await submit . json ( ) ;
created . meetingIds . push ( submittedMeeting . id ) ;
// Give the fan-out a moment to land before counting.
await new Promise ( ( r ) => setTimeout ( r , 250 ) ) ;
2026-05-01 07:30:00 +00:00
const afterCount = await pool . query (
` SELECT COUNT(*)::int AS n
FROM executive_meeting_notifications
WHERE user_id = $ 1
2026-05-01 08:18:29 +00:00
AND notification_type = 'meeting_created' ` ,
2026-05-01 07:30:00 +00:00
[ fresh . id ] ,
) ;
assert . ok (
afterCount . rows [ 0 ] . n > beforeCount . rows [ 0 ] . n ,
2026-05-01 08:18:29 +00:00
"user with no pref rows (post-DELETE) must receive new meeting_created rows" ,
2026-05-01 07:30:00 +00:00
) ;
} ) ;
2026-05-01 21:09:01 +00:00
// ---------------------------------------------------------------------------
// #302 — Cascade-shift on postpone / reschedule.
// Each test uses its own far-future date so the day's meeting set is
// isolated from every other test (and from any seeded data on `today`).
// ---------------------------------------------------------------------------
async function createMeeting ( date , titleEn , startTime , endTime , opts = { } ) {
const res = await api ( adminCookie , "POST" , "/api/executive-meetings" , {
titleAr : titleEn ,
titleEn ,
meetingDate : date ,
startTime ,
endTime ,
platform : "none" ,
status : opts . status ? ? "scheduled" ,
isHighlighted : 0 ,
} ) ;
assert . equal ( res . status , 201 , ` create ${ titleEn } should succeed ` ) ;
const body = await res . json ( ) ;
created . meetingIds . push ( body . id ) ;
return body ;
}
async function getMeeting ( id ) {
const res = await api ( adminCookie , "GET" , ` /api/executive-meetings/ ${ id } ` ) ;
assert . equal ( res . status , 200 , ` GET meeting ${ id } should succeed ` ) ;
return res . json ( ) ;
}
test ( "#302 cascade-preview: returns later active meetings with shifted times" , async ( ) => {
const d = "2050-05-10" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d , "B follower" , "10:30" , "11:00" ) ;
const c = await createMeeting ( d , "C follower" , "11:30" , "12:00" ) ;
// Earlier meeting must NOT be in the preview.
const z = await createMeeting ( d , "Z earlier" , "08:00" , "08:30" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /cascade-preview ` ,
{ deltaMinutes : 30 } ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . equal ( body . blockedBy , null ) ;
const ids = body . followers . map ( ( f ) => f . id ) ;
assert . deepEqual ( ids , [ b . id , c . id ] , "only later active meetings, in start order" ) ;
assert . ok ( ! ids . includes ( z . id ) , "earlier meetings must not be cascaded" ) ;
assert . equal ( body . followers [ 0 ] . newStart . slice ( 0 , 5 ) , "11:00" ) ;
assert . equal ( body . followers [ 0 ] . newEnd . slice ( 0 , 5 ) , "11:30" ) ;
assert . equal ( body . followers [ 1 ] . newStart . slice ( 0 , 5 ) , "12:00" ) ;
} ) ;
test ( "#302 cascade-preview: skips cancelled and completed followers" , async ( ) => {
const d = "2050-05-11" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d , "B cancelled" , "10:30" , "11:00" , {
status : "cancelled" ,
} ) ;
const c = await createMeeting ( d , "C completed" , "11:30" , "12:00" , {
status : "completed" ,
} ) ;
const dActive = await createMeeting ( d , "D active" , "13:00" , "14:00" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /cascade-preview ` ,
{ deltaMinutes : 15 } ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
const ids = body . followers . map ( ( f ) => f . id ) ;
assert . deepEqual ( ids , [ dActive . id ] ) ;
assert . ok ( ! ids . includes ( b . id ) ) ;
assert . ok ( ! ids . includes ( c . id ) ) ;
} ) ;
test ( "#302 postpone-minutes cascadeFollowing=true atomically shifts all later meetings + writes audit" , async ( ) => {
const d = "2050-05-12" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d , "B follower" , "10:30" , "11:00" ) ;
const c = await createMeeting ( d , "C follower" , "11:30" , "12:00" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /postpone-minutes ` ,
{
minutes : 30 ,
expectedUpdatedAt : a . updatedAt ,
cascadeFollowing : true ,
} ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . equal ( body . ok , true ) ;
assert . deepEqual ( body . cascadedIds . sort ( ( x , y ) => x - y ) , [ b . id , c . id ] . sort ( ( x , y ) => x - y ) ) ;
const aFresh = await getMeeting ( a . id ) ;
const bFresh = await getMeeting ( b . id ) ;
const cFresh = await getMeeting ( c . id ) ;
assert . equal ( aFresh . startTime . slice ( 0 , 5 ) , "09:30" ) ;
assert . equal ( aFresh . endTime . slice ( 0 , 5 ) , "10:30" ) ;
assert . equal ( bFresh . startTime . slice ( 0 , 5 ) , "11:00" ) ;
assert . equal ( bFresh . endTime . slice ( 0 , 5 ) , "11:30" ) ;
assert . equal ( cFresh . startTime . slice ( 0 , 5 ) , "12:00" ) ;
assert . equal ( cFresh . endTime . slice ( 0 , 5 ) , "12:30" ) ;
// Audit rows for the cascade — one per follower, naming the trigger.
const audits = await pool . query (
` SELECT entity_id, new_value
FROM executive_meeting_audit_logs
WHERE action = 'meeting_cascade_shift'
AND entity_id = ANY( $ 1::int[])
ORDER BY entity_id ` ,
[ [ b . id , c . id ] ] ,
) ;
assert . equal ( audits . rowCount , 2 , "one cascade audit per follower" ) ;
for ( const row of audits . rows ) {
assert . equal ( row . new _value . triggerMeetingId , a . id ) ;
assert . equal ( row . new _value . deltaMinutes , 30 ) ;
}
} ) ;
test ( "#302 postpone-minutes cascadeFollowing=false leaves followers untouched (no audit)" , async ( ) => {
const d = "2050-05-13" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d , "B follower" , "10:30" , "11:00" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /postpone-minutes ` ,
{
minutes : 15 ,
expectedUpdatedAt : a . updatedAt ,
cascadeFollowing : false ,
} ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . deepEqual ( body . cascadedIds , [ ] ) ;
const aFresh = await getMeeting ( a . id ) ;
const bFresh = await getMeeting ( b . id ) ;
assert . equal ( aFresh . startTime . slice ( 0 , 5 ) , "09:15" , "primary moved" ) ;
assert . equal ( bFresh . startTime . slice ( 0 , 5 ) , "10:30" , "follower unchanged" ) ;
const audits = await pool . query (
` SELECT COUNT(*)::int AS n
FROM executive_meeting_audit_logs
WHERE action = 'meeting_cascade_shift'
AND entity_id = $ 1 ` ,
[ b . id ] ,
) ;
assert . equal ( audits . rows [ 0 ] . n , 0 , "no cascade audit row when flag is false" ) ;
} ) ;
test ( "#302 postpone-minutes cascade midnight rejection rolls back primary too" , async ( ) => {
const d = "2050-05-14" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
// 23:30 follower would be pushed to 24:30 by a 60-minute delta.
const b = await createMeeting ( d , "B late follower" , "23:30" , "23:45" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /postpone-minutes ` ,
{
minutes : 60 ,
expectedUpdatedAt : a . updatedAt ,
cascadeFollowing : true ,
} ,
) ;
assert . equal ( res . status , 400 ) ;
const body = await res . json ( ) ;
assert . equal ( body . code , "cascade_crosses_midnight" ) ;
assert . equal ( body . blockedBy . id , b . id ) ;
// Primary must be unchanged because the whole transaction rolled back.
const aFresh = await getMeeting ( a . id ) ;
assert . equal ( aFresh . startTime . slice ( 0 , 5 ) , "09:00" ) ;
assert . equal ( aFresh . endTime . slice ( 0 , 5 ) , "10:00" ) ;
const bFresh = await getMeeting ( b . id ) ;
assert . equal ( bFresh . startTime . slice ( 0 , 5 ) , "23:30" ) ;
} ) ;
test ( "#302 reschedule cascadeFollowing=true uses (newStart - oldStart) as delta on same day" , async ( ) => {
const d = "2050-05-15" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d , "B follower" , "10:30" , "11:00" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /reschedule ` ,
{
meetingDate : d ,
startTime : "09:45" ,
endTime : "10:45" ,
cascadeFollowing : true ,
} ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . deepEqual ( body . cascadedIds , [ b . id ] ) ;
const bFresh = await getMeeting ( b . id ) ;
// delta = 09:45 - 09:00 = 45m → 10:30 → 11:15
assert . equal ( bFresh . startTime . slice ( 0 , 5 ) , "11:15" ) ;
assert . equal ( bFresh . endTime . slice ( 0 , 5 ) , "11:45" ) ;
} ) ;
test ( "#302 reschedule cascadeFollowing=true is a no-op when date moves" , async ( ) => {
const d1 = "2050-05-16" ;
const d2 = "2050-05-17" ;
const a = await createMeeting ( d1 , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d1 , "B follower" , "10:30" , "11:00" ) ;
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /reschedule ` ,
{
meetingDate : d2 ,
startTime : "09:30" ,
endTime : "10:30" ,
cascadeFollowing : true ,
} ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . deepEqual ( body . cascadedIds , [ ] , "cross-day reschedule must not cascade" ) ;
const bFresh = await getMeeting ( b . id ) ;
assert . equal ( bFresh . startTime . slice ( 0 , 5 ) , "10:30" , "follower untouched" ) ;
} ) ;
test ( "#302 reschedule cascadeFollowing=true is a no-op when newStart <= oldStart" , async ( ) => {
const d = "2050-05-18" ;
const a = await createMeeting ( d , "A primary" , "09:00" , "10:00" ) ;
const b = await createMeeting ( d , "B follower" , "10:30" , "11:00" ) ;
// Reschedule earlier: same day, newStart < oldStart → ignore flag.
const res = await api (
adminCookie ,
"POST" ,
` /api/executive-meetings/ ${ a . id } /reschedule ` ,
{
meetingDate : d ,
startTime : "08:30" ,
endTime : "09:30" ,
cascadeFollowing : true ,
} ,
) ;
assert . equal ( res . status , 200 ) ;
const body = await res . json ( ) ;
assert . deepEqual ( body . cascadedIds , [ ] ) ;
const bFresh = await getMeeting ( b . id ) ;
assert . equal ( bFresh . startTime . slice ( 0 , 5 ) , "10:30" ) ;
} ) ;
2026-05-01 07:30:00 +00:00
test ( "Notification prefs: DELETE on a user with no rows is a 200 no-op" , async ( ) => {
// Cheap idempotence check — clicking Restore defaults twice in a row
// must not 500 or surface a misleading error.
const fresh = await createUser ( "em_pref_restore_noop" , "executive_coordinator" ) ;
const cookie = await login ( fresh . username , TEST _PASSWORD ) ;
const del = await api ( cookie , "DELETE" ,
"/api/executive-meetings/notification-prefs" ) ;
assert . equal ( del . status , 200 ) ;
const body = await del . json ( ) ;
assert . equal ( body . ok , true ) ;
assert . equal ( body . count , 0 ) ;
} ) ;