fix(api): harden notification task status updates

This commit is contained in:
2026-03-31 22:35:02 +02:00
parent 13be8b126b
commit 78d19c59b6
2 changed files with 121 additions and 2 deletions
@@ -336,6 +336,45 @@ describe("notification.createBroadcast", () => {
);
});
it("rejects broadcasts with no recipients before opening a broadcast transaction", async () => {
resolveRecipientsMock.mockResolvedValue([]);
const txCreateBroadcast = vi.fn();
const txUpdateBroadcast = vi.fn();
const tx = {
notificationBroadcast: {
create: txCreateBroadcast,
update: txUpdateBroadcast,
},
};
const transaction = vi.fn(async (callback: (db: typeof tx) => Promise<unknown>) => callback(tx));
const create = vi.fn();
const update = vi.fn();
const db = {
$transaction: transaction,
notificationBroadcast: {
create,
update,
},
};
const caller = createManagerCaller(db);
await expect(caller.createBroadcast({
title: "Ops update",
targetType: "all",
})).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "No recipients matched the broadcast target.",
});
expect(transaction).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(txCreateBroadcast).not.toHaveBeenCalled();
expect(txUpdateBroadcast).not.toHaveBeenCalled();
});
it("rejects scheduled broadcasts when no recipients match the target", async () => {
resolveRecipientsMock.mockResolvedValue([]);
@@ -1103,6 +1142,7 @@ describe("notification.updateTaskStatus", () => {
where: {
id: "task_1",
OR: [{ userId: "user_1" }, { assigneeId: "user_1" }],
category: { in: ["TASK", "APPROVAL"] },
},
});
expect(update).toHaveBeenCalledWith({
@@ -1138,11 +1178,87 @@ describe("notification.updateTaskStatus", () => {
expect(db.notification.update).toHaveBeenCalledWith({
where: { id: "task_2" },
data: { taskStatus: "IN_PROGRESS" },
data: {
taskStatus: "IN_PROGRESS",
completedAt: null,
completedBy: null,
},
});
expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(1, "user_1", "task_2");
expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(2, "user_3", "task_2");
});
it("clears completion metadata when reopening a completed task", async () => {
const db = withUserLookup({
notification: {
findFirst: vi.fn().mockResolvedValue({
id: "task_3",
userId: "user_1",
assigneeId: "user_3",
taskStatus: "DONE",
completedAt: new Date("2026-04-01T10:00:00Z"),
completedBy: "user_2",
}),
update: vi.fn().mockResolvedValue({
id: "task_3",
taskStatus: "OPEN",
completedAt: null,
completedBy: null,
}),
},
});
const caller = createProtectedCaller(db);
const result = await caller.updateTaskStatus({ id: "task_3", status: "OPEN" });
expect(result).toMatchObject({
id: "task_3",
taskStatus: "OPEN",
completedAt: null,
completedBy: null,
});
expect(db.notification.update).toHaveBeenCalledWith({
where: { id: "task_3" },
data: {
taskStatus: "OPEN",
completedAt: null,
completedBy: null,
},
});
expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(1, "user_1", "task_3");
expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(2, "user_3", "task_3");
expect(emitTaskCompleted).not.toHaveBeenCalled();
});
it("rejects task status updates for non-task notifications", async () => {
const findFirst = vi.fn().mockResolvedValue(null);
const update = vi.fn();
const db = withUserLookup({
notification: {
findFirst,
update,
},
});
const caller = createProtectedCaller(db);
await expect(caller.updateTaskStatus({ id: "notif_info_1", status: "DONE" })).rejects.toMatchObject(
{
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
},
);
expect(findFirst).toHaveBeenCalledWith({
where: {
id: "notif_info_1",
OR: [{ userId: "user_1" }, { assigneeId: "user_1" }],
category: { in: ["TASK", "APPROVAL"] },
},
});
expect(update).not.toHaveBeenCalled();
expect(emitTaskCompleted).not.toHaveBeenCalled();
expect(emitTaskStatusChanged).not.toHaveBeenCalled();
});
});
describe("notification.assignTask", () => {