73c9953675
Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with backend access checks (admin sees everything), realtime updates, toasts, an unread badge on the Notes app tile, and full i18n + RTL. Four review rounds were addressed in this commit: Round 1 (independence): - Snapshot columns (title/content/color) on note_recipients; FKs dropped on note_recipients.note_id and note_replies.note_id so recipient threads survive the sender deleting their note. - /notes/received and /notes/:id/thread render the recipient snapshot. Round 2: - POST /notes/:id/reply: owner is now allowed to reply too. - Added GET /notes/:id as alias of /notes/:id/thread. - Added OpenAPI ops for the Notes routes; ran orval codegen. - use-notifications-socket.ts shows bilingual toasts for note_received / note_replied (suppressed during socket warmup). - home.tsx renders an unread badge on the Notes app tile. Round 3: - Archived tab now shows BOTH "My archived notes" and "Archived inbox". - Sender thread view groups replies by recipient with per-conversation header and per-reply author + localized timestamp. - Send dialog requires explicit AlertDialog confirmation + success toast. - Realtime toast strings moved off hardcoded EN/AR to i18n keys. - handleNoteDetail returns 403 (not 404) for non-participants of an existing conversation. - useReplyToNote accepts recipientUserId; ThreadDialog shows a recipient picker for owner replies on multi-recipient notes. Round 5 (this round): admin authorization fix - handleNoteDetail: admin bypass is now evaluated BEFORE the "non-participant 403" branch. When the sender has deleted the live note row but recipient snapshots remain, an admin GET /notes/:id now returns 200 with thread payload assembled from a fallback recipient snapshot (instead of 403). - New regression test: "admin can read a note whose sender deleted their copy (recipient snapshot remains)". Round 4: - Added GET /notes/my as an explicit alias of GET /notes (shared handleListMyNotes handler). - Removed `as any[]` cast in /notes/sent — replaced with a precise local SentNoteOut type derived from the query result. - Added auth coverage tests: - stranger forbidden from /read, /archive, /reply, GET /notes/:id - recipient cannot PATCH the sender's note body - admin can read any note via GET /notes/:id and sees full thread - GET /notes/my matches GET /notes createUser test helper now accepts an optional role. Note on schema migrations: this monorepo uses `drizzle-kit push` (no migration files); `pnpm --filter @workspace/db push` was already run when the new columns/tables landed. Tests: 11 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox) all pass. tx-os typecheck is clean. Architect re-review: PASS. Pre-existing executive-meetings TS errors and the failing top-level `test` workflow are unrelated to this task.
521 lines
19 KiB
JavaScript
521 lines
19 KiB
JavaScript
// API tests for the in-app messaging flow built on top of personal notes.
|
|
// Covers POST /api/notes/:id/send, GET /api/notes/sent, GET /api/notes/received,
|
|
// GET /api/notes/:id/thread, POST /api/notes/:id/read, POST /api/notes/:id/reply,
|
|
// and the access-control rules around them.
|
|
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import pg from "pg";
|
|
|
|
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error("DATABASE_URL must be set to run these tests");
|
|
}
|
|
|
|
const TEST_PASSWORD = "TestPass123!";
|
|
const TEST_PASSWORD_HASH =
|
|
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
|
|
const createdUserIds = [];
|
|
const createdNoteIds = [];
|
|
|
|
function uniq() {
|
|
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
async function createUser(prefix, role = "user") {
|
|
const username = `${prefix}_${uniq()}`;
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
|
);
|
|
const id = rows[0].id;
|
|
createdUserIds.push(id);
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id)
|
|
SELECT $1, id FROM roles WHERE name = $2`,
|
|
[id, role],
|
|
);
|
|
return { id, username };
|
|
}
|
|
|
|
async function login(username) {
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password: TEST_PASSWORD }),
|
|
});
|
|
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
|
const setCookie = res.headers.get("set-cookie");
|
|
const sid = setCookie
|
|
.split(",")
|
|
.map((c) => c.split(";")[0].trim())
|
|
.find((c) => c.startsWith("connect.sid="));
|
|
assert.ok(sid, "expected connect.sid cookie");
|
|
return sid;
|
|
}
|
|
|
|
async function api(path, cookie, init = {}) {
|
|
return fetch(`${API_BASE}/api${path}`, {
|
|
...init,
|
|
headers: {
|
|
Cookie: cookie,
|
|
"Content-Type": "application/json",
|
|
...(init.headers ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createNote(cookie, body) {
|
|
const res = await api("/notes", cookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: "Hi",
|
|
content: "hello world",
|
|
color: "blue",
|
|
...body,
|
|
}),
|
|
});
|
|
assert.ok(
|
|
res.status === 200 || res.status === 201,
|
|
`create note expected 2xx, got ${res.status}`,
|
|
);
|
|
const json = await res.json();
|
|
createdNoteIds.push(json.id);
|
|
return json;
|
|
}
|
|
|
|
after(async () => {
|
|
if (createdNoteIds.length > 0) {
|
|
await pool.query(
|
|
`DELETE FROM note_replies WHERE note_id = ANY($1::int[])`,
|
|
[createdNoteIds],
|
|
);
|
|
await pool.query(
|
|
`DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`,
|
|
[createdNoteIds],
|
|
);
|
|
await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [
|
|
createdNoteIds,
|
|
]);
|
|
}
|
|
if (createdUserIds.length > 0) {
|
|
await pool.query(
|
|
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
|
|
[createdUserIds],
|
|
);
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
|
createdUserIds,
|
|
]);
|
|
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
|
createdUserIds,
|
|
]);
|
|
}
|
|
await pool.end();
|
|
});
|
|
|
|
test("send → received → read → reply produces independent sender / recipient views", async () => {
|
|
const sender = await createUser("notes_sender");
|
|
const r1 = await createUser("notes_r1");
|
|
const r2 = await createUser("notes_r2");
|
|
|
|
const sCookie = await login(sender.username);
|
|
const r1Cookie = await login(r1.username);
|
|
const r2Cookie = await login(r2.username);
|
|
|
|
const note = await createNote(sCookie);
|
|
|
|
// Send to both recipients (and try to include sender's own id to confirm it
|
|
// gets filtered out).
|
|
const sendRes = await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [r1.id, r2.id, sender.id] }),
|
|
});
|
|
assert.equal(sendRes.status, 200);
|
|
const sendBody = await sendRes.json();
|
|
assert.equal(sendBody.success, true);
|
|
assert.equal(sendBody.sent, 2, "self-recipient must be excluded from the send");
|
|
|
|
// Sender's "Sent" view shows the note with two unread recipients.
|
|
const sentRes = await api(`/notes/sent`, sCookie);
|
|
assert.equal(sentRes.status, 200);
|
|
const sent = await sentRes.json();
|
|
const sentNote = sent.find((n) => n.id === note.id);
|
|
assert.ok(sentNote, "sent note must appear in /notes/sent");
|
|
assert.equal(sentNote.recipients.length, 2);
|
|
assert.ok(
|
|
sentNote.recipients.every((r) => r.status === "unread"),
|
|
"all recipients should start as unread",
|
|
);
|
|
|
|
// r1 sees the note in their inbox.
|
|
const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json();
|
|
const r1Note = r1Inbox.find((n) => n.id === note.id);
|
|
assert.ok(r1Note, "r1 must see the note in their inbox");
|
|
assert.equal(r1Note.status, "unread");
|
|
assert.equal(r1Note.sender.id, sender.id);
|
|
|
|
// r1 marks read → status flips for that recipient row only.
|
|
const readRes = await api(`/notes/${note.id}/read`, r1Cookie, {
|
|
method: "POST",
|
|
});
|
|
assert.equal(readRes.status, 200);
|
|
|
|
const sentAfterRead = await (await api(`/notes/sent`, sCookie)).json();
|
|
const sentNote2 = sentAfterRead.find((n) => n.id === note.id);
|
|
const r1Row = sentNote2.recipients.find((r) => r.recipientUserId === r1.id);
|
|
const r2Row = sentNote2.recipients.find((r) => r.recipientUserId === r2.id);
|
|
assert.equal(r1Row.status, "read");
|
|
assert.equal(r2Row.status, "unread", "r2 must remain unread");
|
|
|
|
// r1 replies → status becomes 'replied' for r1 only, reply is visible.
|
|
const replyRes = await api(`/notes/${note.id}/reply`, r1Cookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "got it, thanks" }),
|
|
});
|
|
assert.ok(
|
|
replyRes.status === 200 || replyRes.status === 201,
|
|
`reply expected 2xx, got ${replyRes.status}`,
|
|
);
|
|
|
|
const threadSender = await (
|
|
await api(`/notes/${note.id}/thread`, sCookie)
|
|
).json();
|
|
assert.equal(threadSender.isOwner, true);
|
|
assert.equal(threadSender.replies.length, 1);
|
|
assert.equal(threadSender.replies[0].content, "got it, thanks");
|
|
const r1RowAfter = threadSender.recipients.find(
|
|
(r) => r.recipientUserId === r1.id,
|
|
);
|
|
assert.equal(r1RowAfter.status, "replied");
|
|
|
|
// r2's recipient view must NOT show r1's reply (recipient sees only their
|
|
// own thread of replies).
|
|
const threadR2 = await (
|
|
await api(`/notes/${note.id}/thread`, r2Cookie)
|
|
).json();
|
|
assert.equal(threadR2.isOwner, false);
|
|
assert.equal(threadR2.myStatus, "unread");
|
|
assert.equal(
|
|
threadR2.replies.length,
|
|
0,
|
|
"r2 must not see replies authored by r1",
|
|
);
|
|
});
|
|
|
|
test("non-participant gets 403 on /notes/:id/thread", async () => {
|
|
const sender = await createUser("notes_p_sender");
|
|
const recipient = await createUser("notes_p_recipient");
|
|
const stranger = await createUser("notes_p_stranger");
|
|
|
|
const sCookie = await login(sender.username);
|
|
const strangerCookie = await login(stranger.username);
|
|
|
|
const note = await createNote(sCookie);
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
|
|
const res = await api(`/notes/${note.id}/thread`, strangerCookie);
|
|
assert.equal(res.status, 403, "stranger must not access the thread");
|
|
});
|
|
|
|
test("recipient copy is independent: sender edit/delete does not mutate it", async () => {
|
|
const sender = await createUser("notes_indep_sender");
|
|
const recipient = await createUser("notes_indep_recipient");
|
|
const sCookie = await login(sender.username);
|
|
const rCookie = await login(recipient.username);
|
|
|
|
const note = await createNote(sCookie, {
|
|
title: "Original title",
|
|
content: "Original content",
|
|
color: "blue",
|
|
});
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
|
|
// Sender edits + deletes the note.
|
|
const editRes = await api(`/notes/${note.id}`, sCookie, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ title: "EDITED", content: "EDITED body", color: "red" }),
|
|
});
|
|
assert.ok(editRes.ok, `edit expected 2xx, got ${editRes.status}`);
|
|
|
|
const inboxAfterEdit = await (await api(`/notes/received`, rCookie)).json();
|
|
const r1 = inboxAfterEdit.find((n) => n.id === note.id);
|
|
assert.ok(r1, "recipient must still see the delivered copy after sender edit");
|
|
assert.equal(r1.title, "Original title", "recipient title must be the snapshot");
|
|
assert.equal(r1.content, "Original content", "recipient content must be the snapshot");
|
|
assert.equal(r1.color, "blue", "recipient color must be the snapshot");
|
|
|
|
// Now sender deletes their copy.
|
|
const delRes = await api(`/notes/${note.id}`, sCookie, { method: "DELETE" });
|
|
assert.ok(delRes.ok, `delete expected 2xx, got ${delRes.status}`);
|
|
|
|
const inboxAfterDelete = await (await api(`/notes/received`, rCookie)).json();
|
|
const r2 = inboxAfterDelete.find(
|
|
(n) => n.recipientRowId === r1.recipientRowId,
|
|
);
|
|
assert.ok(
|
|
r2,
|
|
"recipient must still see the delivered copy after sender deletion",
|
|
);
|
|
assert.equal(r2.title, "Original title");
|
|
assert.equal(r2.content, "Original content");
|
|
|
|
// Recipient can still open the thread + reply even after sender deletion.
|
|
const threadRes = await api(`/notes/${note.id}/thread`, rCookie);
|
|
assert.equal(threadRes.status, 200, "thread still readable after sender delete");
|
|
const thread = await threadRes.json();
|
|
assert.equal(thread.title, "Original title");
|
|
assert.equal(thread.content, "Original content");
|
|
|
|
const replyRes = await api(`/notes/${note.id}/reply`, rCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "still here" }),
|
|
});
|
|
assert.ok(
|
|
replyRes.ok,
|
|
`reply after sender delete expected 2xx, got ${replyRes.status}`,
|
|
);
|
|
});
|
|
|
|
test("reply on an archived recipient row clears archivedAt and bumps to replied", async () => {
|
|
const sender = await createUser("notes_arch_sender");
|
|
const recipient = await createUser("notes_arch_recipient");
|
|
const sCookie = await login(sender.username);
|
|
const rCookie = await login(recipient.username);
|
|
|
|
const note = await createNote(sCookie);
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
const archRes = await api(`/notes/${note.id}/archive`, rCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ archived: true }),
|
|
});
|
|
assert.ok(archRes.ok);
|
|
|
|
const replyRes = await api(`/notes/${note.id}/reply`, rCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "back from archive" }),
|
|
});
|
|
assert.ok(replyRes.ok, `reply expected 2xx, got ${replyRes.status}`);
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT status, archived_at FROM note_recipients
|
|
WHERE note_id = $1 AND recipient_user_id = $2`,
|
|
[note.id, recipient.id],
|
|
);
|
|
assert.equal(rows[0].status, "replied");
|
|
assert.equal(rows[0].archived_at, null, "archivedAt must be cleared on reply");
|
|
});
|
|
|
|
test("owner can reply on their own note; recipient status is preserved", async () => {
|
|
const sender = await createUser("notes_oreply_sender");
|
|
const recipient = await createUser("notes_oreply_recipient");
|
|
const sCookie = await login(sender.username);
|
|
const rCookie = await login(recipient.username);
|
|
|
|
const note = await createNote(sCookie);
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
|
|
// Recipient leaves the note unread, then the owner replies. Owner reply
|
|
// must succeed (not 403) and must NOT flip the recipient's status.
|
|
const ownerReply = await api(`/notes/${note.id}/reply`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "owner says hi" }),
|
|
});
|
|
assert.equal(
|
|
ownerReply.status,
|
|
201,
|
|
`owner reply expected 201, got ${ownerReply.status}`,
|
|
);
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT status FROM note_recipients
|
|
WHERE note_id = $1 AND recipient_user_id = $2`,
|
|
[note.id, recipient.id],
|
|
);
|
|
assert.equal(rows[0].status, "unread", "owner reply must not flip recipient status");
|
|
|
|
// Recipient sees the owner reply in their thread.
|
|
const thread = await (await api(`/notes/${note.id}/thread`, rCookie)).json();
|
|
assert.ok(
|
|
thread.replies.some((r) => r.content === "owner says hi"),
|
|
"recipient must see the owner's reply in the thread",
|
|
);
|
|
});
|
|
|
|
test("GET /notes/:id is the same payload as /notes/:id/thread", async () => {
|
|
const sender = await createUser("notes_alias_sender");
|
|
const recipient = await createUser("notes_alias_recipient");
|
|
const sCookie = await login(sender.username);
|
|
const rCookie = await login(recipient.username);
|
|
const note = await createNote(sCookie);
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
const a = await (await api(`/notes/${note.id}`, rCookie)).json();
|
|
const b = await (await api(`/notes/${note.id}/thread`, rCookie)).json();
|
|
assert.equal(a.id, b.id);
|
|
assert.equal(a.title, b.title);
|
|
assert.equal(a.content, b.content);
|
|
assert.equal(a.color, b.color);
|
|
assert.equal(a.myStatus, b.myStatus);
|
|
});
|
|
|
|
test("stranger is forbidden from /read, /archive, and /reply on someone else's note", async () => {
|
|
const sender = await createUser("notes_strg_sender");
|
|
const recipient = await createUser("notes_strg_recipient");
|
|
const stranger = await createUser("notes_strg_stranger");
|
|
const sCookie = await login(sender.username);
|
|
const xCookie = await login(stranger.username);
|
|
|
|
const note = await createNote(sCookie);
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
|
|
const readRes = await api(`/notes/${note.id}/read`, xCookie, { method: "POST" });
|
|
assert.equal(readRes.status, 403, "stranger /read must be 403");
|
|
|
|
const archiveRes = await api(`/notes/${note.id}/archive`, xCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ archived: true }),
|
|
});
|
|
assert.equal(archiveRes.status, 403, "stranger /archive must be 403");
|
|
|
|
const replyRes = await api(`/notes/${note.id}/reply`, xCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "I shouldn't be here" }),
|
|
});
|
|
assert.equal(replyRes.status, 403, "stranger /reply must be 403");
|
|
|
|
const detailRes = await api(`/notes/${note.id}`, xCookie);
|
|
assert.equal(detailRes.status, 403, "stranger GET /notes/:id must be 403");
|
|
});
|
|
|
|
test("recipient cannot PATCH the sender's note body", async () => {
|
|
const sender = await createUser("notes_patchdeny_sender");
|
|
const recipient = await createUser("notes_patchdeny_recipient");
|
|
const sCookie = await login(sender.username);
|
|
const rCookie = await login(recipient.username);
|
|
|
|
const note = await createNote(sCookie, { title: "Untouched" });
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
|
|
const res = await api(`/notes/${note.id}`, rCookie, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ title: "HACKED" }),
|
|
});
|
|
assert.equal(res.status, 404, "recipient PATCH must be denied");
|
|
|
|
const { rows } = await pool.query(`SELECT title FROM notes WHERE id = $1`, [note.id]);
|
|
assert.equal(rows[0].title, "Untouched", "sender's note body must be unchanged");
|
|
});
|
|
|
|
test("admin can read any note via GET /notes/:id and sees full thread", async () => {
|
|
const sender = await createUser("notes_adm_sender");
|
|
const recipient = await createUser("notes_adm_recipient");
|
|
const adminUser = await createUser("notes_adm_admin", "admin");
|
|
const sCookie = await login(sender.username);
|
|
const rCookie = await login(recipient.username);
|
|
const aCookie = await login(adminUser.username);
|
|
|
|
const note = await createNote(sCookie, { title: "Confidential" });
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
await api(`/notes/${note.id}/reply`, rCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "recipient says hi" }),
|
|
});
|
|
|
|
const res = await api(`/notes/${note.id}`, aCookie);
|
|
assert.equal(res.status, 200, "admin must be able to read any note");
|
|
const body = await res.json();
|
|
assert.equal(body.isAdmin, true);
|
|
assert.equal(body.title, "Confidential");
|
|
assert.ok(body.recipients.length >= 1, "admin sees full recipient list");
|
|
assert.ok(
|
|
body.replies.some((r) => r.content === "recipient says hi"),
|
|
"admin sees full reply thread",
|
|
);
|
|
});
|
|
|
|
test("admin can read a note whose sender deleted their copy (recipient snapshot remains)", async () => {
|
|
const sender = await createUser("notes_admdel_sender");
|
|
const recipient = await createUser("notes_admdel_recipient");
|
|
const adminUser = await createUser("notes_admdel_admin", "admin");
|
|
const sCookie = await login(sender.username);
|
|
const aCookie = await login(adminUser.username);
|
|
|
|
const note = await createNote(sCookie, { title: "WillDelete" });
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
// Sender deletes the live note row; recipient snapshot must remain.
|
|
await pool.query(`DELETE FROM notes WHERE id = $1`, [note.id]);
|
|
|
|
const res = await api(`/notes/${note.id}`, aCookie);
|
|
assert.equal(res.status, 200, "admin must still read the thread after sender delete");
|
|
const body = await res.json();
|
|
assert.equal(body.isAdmin, true);
|
|
assert.equal(body.title, "WillDelete", "admin sees recipient snapshot title");
|
|
assert.ok(body.recipients.length >= 1, "admin sees recipient list");
|
|
});
|
|
|
|
test("GET /notes/my returns the same payload as GET /notes (alias)", async () => {
|
|
const owner = await createUser("notes_my_owner");
|
|
const cookie = await login(owner.username);
|
|
await createNote(cookie, { title: "from /notes/my" });
|
|
const a = await (await api(`/notes`, cookie)).json();
|
|
const b = await (await api(`/notes/my`, cookie)).json();
|
|
assert.equal(a.length, b.length);
|
|
assert.deepEqual(
|
|
a.map((n) => n.id).sort(),
|
|
b.map((n) => n.id).sort(),
|
|
);
|
|
});
|
|
|
|
test("re-sending to the same recipient does not duplicate the recipient row", async () => {
|
|
const sender = await createUser("notes_dup_sender");
|
|
const recipient = await createUser("notes_dup_recipient");
|
|
const sCookie = await login(sender.username);
|
|
|
|
const note = await createNote(sCookie);
|
|
await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
const second = await api(`/notes/${note.id}/send`, sCookie, {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
|
|
});
|
|
assert.equal(second.status, 200);
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT count(*)::int AS c FROM note_recipients WHERE note_id = $1`,
|
|
[note.id],
|
|
);
|
|
assert.equal(rows[0].c, 1, "recipient row must not be duplicated");
|
|
});
|