@open-mercato/scheduler 0.6.5-develop.5187.1.82e5532561 → 0.6.5-develop.5212.1.b47932beef

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,123 +1,56 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
- import { skipIfUndoTestsDisabled } from "@open-mercato/core/helpers/integration/undoHarness";
4
- async function settleAuditClock() {
5
- await new Promise((resolve) => setTimeout(resolve, 50));
3
+ import {
4
+ extractOperation,
5
+ runCrudUndoRoundTrip,
6
+ skipIfUndoTestsDisabled
7
+ } from "@open-mercato/core/helpers/integration/undoHarness";
8
+ import {
9
+ SCHEDULER_JOBS_PATH,
10
+ SCHEDULER_TRIGGER_PATH,
11
+ createScheduleJob,
12
+ deleteScheduleJob
13
+ } from "./helpers/scheduler.js";
14
+ function jobCreatePayload(stamp) {
15
+ return {
16
+ name: `TC-UNDO-001 Scheduler ${stamp}`,
17
+ description: "TC-UNDO-001 undo/redo probe",
18
+ scopeType: "organization",
19
+ scheduleType: "interval",
20
+ scheduleValue: "15m",
21
+ timezone: "UTC",
22
+ targetType: "queue",
23
+ targetQueue: "scheduler-execution",
24
+ isEnabled: true,
25
+ sourceType: "user"
26
+ };
6
27
  }
7
- function readUndoToken(res) {
8
- const header = res.headers()["x-om-operation"] ?? "";
9
- const enc = header.startsWith("omop:") ? header.slice(5) : "";
10
- expect(enc, "x-om-operation header should carry an omop: payload").not.toBe("");
11
- const payload = JSON.parse(decodeURIComponent(enc));
12
- expect(typeof payload.undoToken, "undoToken present in operation payload").toBe("string");
13
- return payload.undoToken;
14
- }
15
- async function getById(request, token, id) {
16
- const res = await apiRequest(request, "GET", `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token });
17
- expect(res.status()).toBe(200);
18
- const body = await res.json();
19
- return (body.items ?? [])[0];
20
- }
21
- async function createJob(request, token, name) {
22
- const res = await apiRequest(request, "POST", "/api/scheduler/jobs", {
23
- token,
24
- data: {
25
- name,
26
- description: "TC-UNDO-001 probe",
27
- scopeType: "tenant",
28
- scheduleType: "cron",
29
- scheduleValue: "0 0 * * *",
30
- timezone: "UTC",
31
- targetType: "queue",
32
- targetQueue: "default",
33
- isEnabled: true,
34
- sourceType: "user"
35
- }
36
- });
37
- expect(res.status(), "create returns 201").toBe(201);
38
- const id = (await res.json()).id;
39
- expect(id, "create returns an id").toBeTruthy();
40
- return { id, res };
41
- }
42
- async function undo(request, token, undoToken) {
43
- const res = await apiRequest(request, "POST", "/api/audit_logs/audit-logs/actions/undo", {
44
- token,
45
- data: { undoToken }
46
- });
47
- const raw = await res.text();
48
- expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200);
49
- const body = JSON.parse(raw);
50
- expect(body.ok, "undo body is { ok: true }").toBe(true);
51
- }
52
- async function cleanup(request, token, id) {
53
- if (!token || !id) return;
54
- try {
55
- await apiRequest(request, "DELETE", "/api/scheduler/jobs", { token, data: { id } });
56
- } catch {
57
- }
58
- }
59
- test.describe("TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)", () => {
28
+ test.describe("TC-UNDO-001 scheduler.jobs undo/redo (#2504)", () => {
60
29
  test.beforeAll(() => {
61
30
  skipIfUndoTestsDisabled();
62
31
  });
63
- test("UPDATE -> undo restores the prior name", async ({ request }) => {
64
- let token = null;
65
- let id = null;
66
- try {
67
- token = await getAuthToken(request, "admin");
68
- const originalName = `TC-UNDO-001 Update ${Date.now()}`;
69
- ({ id } = await createJob(request, token, originalName));
70
- await settleAuditClock();
71
- const updateRes = await apiRequest(request, "PUT", "/api/scheduler/jobs", {
72
- token,
73
- data: { id, name: `${originalName} RENAMED` }
74
- });
75
- expect(updateRes.status(), "update returns 200").toBe(200);
76
- expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`);
77
- await undo(request, token, readUndoToken(updateRes));
78
- const restored = await getById(request, token, id);
79
- expect(restored, "job still exists after update-undo").toBeTruthy();
80
- expect(restored.name, "name restored to original by undo").toBe(originalName);
81
- } finally {
82
- await cleanup(request, token, id);
83
- }
32
+ test("jobs CRUD commands restore scalar state on undo/redo (I1/I2/I3/I5/I6)", async ({ request }) => {
33
+ const token = await getAuthToken(request, "admin");
34
+ await runCrudUndoRoundTrip(request, token, {
35
+ label: "scheduler.jobs",
36
+ collectionPath: SCHEDULER_JOBS_PATH,
37
+ field: "name",
38
+ createPayload: jobCreatePayload,
39
+ updatePayload: (id, stamp) => ({ id, name: `TC-UNDO-001 Scheduler Renamed ${stamp}` })
40
+ });
84
41
  });
85
- test("DELETE -> undo re-lists the soft-deleted job", async ({ request }) => {
86
- let token = null;
87
- let id = null;
42
+ test("\xA74 trigger exposes no undo token", async ({ request }) => {
43
+ const token = await getAuthToken(request, "admin");
44
+ let jobId = null;
88
45
  try {
89
- token = await getAuthToken(request, "admin");
90
- const name = `TC-UNDO-001 Delete ${Date.now()}`;
91
- ({ id } = await createJob(request, token, name));
92
- await settleAuditClock();
93
- const deleteRes = await apiRequest(request, "DELETE", "/api/scheduler/jobs", {
46
+ jobId = await createScheduleJob(request, token);
47
+ const triggerRes = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
94
48
  token,
95
- data: { id }
49
+ data: { id: jobId }
96
50
  });
97
- expect(deleteRes.status(), "delete returns 200").toBe(200);
98
- expect(await getById(request, token, id), "job is gone after delete").toBeFalsy();
99
- await undo(request, token, readUndoToken(deleteRes));
100
- const restored = await getById(request, token, id);
101
- expect(restored, "job re-materialized by delete-undo").toBeTruthy();
102
- expect(restored.name).toBe(name);
103
- } finally {
104
- await cleanup(request, token, id);
105
- }
106
- });
107
- test("CREATE -> undo removes the created job", async ({ request }) => {
108
- let token = null;
109
- let id = null;
110
- try {
111
- token = await getAuthToken(request, "admin");
112
- const name = `TC-UNDO-001 Create ${Date.now()}`;
113
- const created = await createJob(request, token, name);
114
- id = created.id;
115
- expect(await getById(request, token, id), "job exists after create").toBeTruthy();
116
- await undo(request, token, readUndoToken(created.res));
117
- expect(await getById(request, token, id), "job removed by create-undo").toBeFalsy();
118
- id = null;
51
+ expect(extractOperation(triggerRes), "trigger response carries no undo token (\xA74)").toBeNull();
119
52
  } finally {
120
- await cleanup(request, token, id);
53
+ await deleteScheduleJob(request, token, jobId);
121
54
  }
122
55
  });
123
56
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts"],
4
- "sourcesContent": ["import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { skipIfUndoTestsDisabled } from '@open-mercato/core/helpers/integration/undoHarness'\n\n/**\n * TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).\n *\n * Before the fix, every scheduler.jobs undo handler read `logEntry.payload`\n * (always undefined \u2014 the command bus persists the undo snapshot under\n * `commandPayload`), so undo returned `{ ok: true }` while silently changing\n * nothing. This spec exercises the real HTTP path end-to-end:\n * - CREATE -> undo => job is soft-deleted (no longer listed)\n * - UPDATE -> undo => renamed field is restored to its prior value\n * - DELETE -> undo => soft-deleted job is re-materialized\n *\n * Endpoints covered:\n * - POST /api/scheduler/jobs (create)\n * - GET /api/scheduler/jobs?id= (read)\n * - PUT /api/scheduler/jobs (update)\n * - DELETE /api/scheduler/jobs (delete, cleanup)\n * - POST /api/audit_logs/audit-logs/actions/undo (undo)\n */\n\ntype ScheduleRow = {\n id: string\n name: string\n description: string | null\n scheduleValue: string\n}\n\n// The undo endpoint only honors the *latest* undoable log for a resource, and\n// `latestUndoableForResource` orders by `created_at` alone. Audit `created_at` is\n// millisecond-precision (`new Date()` at log time), so a create + a follow-up\n// mutation issued within the same millisecond tie and the lookup may resolve to\n// the wrong log. A short settle guarantees the operation under undo is strictly\n// the most recent, keeping the round-trip deterministic.\nasync function settleAuditClock(): Promise<void> {\n await new Promise((resolve) => setTimeout(resolve, 50))\n}\n\nfunction readUndoToken(res: APIResponse): string {\n const header = res.headers()['x-om-operation'] ?? ''\n const enc = header.startsWith('omop:') ? header.slice(5) : ''\n expect(enc, 'x-om-operation header should carry an omop: payload').not.toBe('')\n const payload = JSON.parse(decodeURIComponent(enc)) as { undoToken?: string }\n expect(typeof payload.undoToken, 'undoToken present in operation payload').toBe('string')\n return payload.undoToken as string\n}\n\nasync function getById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<ScheduleRow | undefined> {\n const res = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token })\n expect(res.status()).toBe(200)\n const body = (await res.json()) as { items?: ScheduleRow[] }\n return (body.items ?? [])[0]\n}\n\nasync function createJob(request: APIRequestContext, token: string, name: string): Promise<{ id: string; res: APIResponse }> {\n const res = await apiRequest(request, 'POST', '/api/scheduler/jobs', {\n token,\n data: {\n name,\n description: 'TC-UNDO-001 probe',\n scopeType: 'tenant',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'default',\n isEnabled: true,\n sourceType: 'user',\n },\n })\n expect(res.status(), 'create returns 201').toBe(201)\n const id = ((await res.json()) as { id: string }).id\n expect(id, 'create returns an id').toBeTruthy()\n return { id, res }\n}\n\nasync function undo(request: APIRequestContext, token: string, undoToken: string): Promise<void> {\n const res = await apiRequest(request, 'POST', '/api/audit_logs/audit-logs/actions/undo', {\n token,\n data: { undoToken },\n })\n const raw = await res.text()\n expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200)\n const body = JSON.parse(raw) as { ok?: boolean }\n expect(body.ok, 'undo body is { ok: true }').toBe(true)\n}\n\nasync function cleanup(request: APIRequestContext, token: string | null, id: string | null): Promise<void> {\n if (!token || !id) return\n try {\n await apiRequest(request, 'DELETE', '/api/scheduler/jobs', { token, data: { id } })\n } catch {\n /* ignore */\n }\n}\n\ntest.describe('TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)', () => {\n test.beforeAll(() => {\n skipIfUndoTestsDisabled()\n })\n\n test('UPDATE -> undo restores the prior name', async ({ request }) => {\n let token: string | null = null\n let id: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const originalName = `TC-UNDO-001 Update ${Date.now()}`\n ;({ id } = await createJob(request, token, originalName))\n await settleAuditClock()\n\n const updateRes = await apiRequest(request, 'PUT', '/api/scheduler/jobs', {\n token,\n data: { id, name: `${originalName} RENAMED` },\n })\n expect(updateRes.status(), 'update returns 200').toBe(200)\n expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`)\n\n await undo(request, token, readUndoToken(updateRes))\n\n const restored = await getById(request, token, id)\n expect(restored, 'job still exists after update-undo').toBeTruthy()\n expect(restored!.name, 'name restored to original by undo').toBe(originalName)\n } finally {\n await cleanup(request, token, id)\n }\n })\n\n test('DELETE -> undo re-lists the soft-deleted job', async ({ request }) => {\n let token: string | null = null\n let id: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const name = `TC-UNDO-001 Delete ${Date.now()}`\n ;({ id } = await createJob(request, token, name))\n await settleAuditClock()\n\n const deleteRes = await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {\n token,\n data: { id },\n })\n expect(deleteRes.status(), 'delete returns 200').toBe(200)\n expect(await getById(request, token, id), 'job is gone after delete').toBeFalsy()\n\n await undo(request, token, readUndoToken(deleteRes))\n\n const restored = await getById(request, token, id)\n expect(restored, 'job re-materialized by delete-undo').toBeTruthy()\n expect(restored!.name).toBe(name)\n } finally {\n await cleanup(request, token, id)\n }\n })\n\n test('CREATE -> undo removes the created job', async ({ request }) => {\n let token: string | null = null\n let id: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const name = `TC-UNDO-001 Create ${Date.now()}`\n const created = await createJob(request, token, name)\n id = created.id\n\n expect(await getById(request, token, id), 'job exists after create').toBeTruthy()\n\n await undo(request, token, readUndoToken(created.res))\n\n expect(await getById(request, token, id), 'job removed by create-undo').toBeFalsy()\n id = null // already undone; nothing to clean up\n } finally {\n await cleanup(request, token, id)\n }\n })\n})\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport {\n extractOperation,\n runCrudUndoRoundTrip,\n skipIfUndoTestsDisabled,\n} from '@open-mercato/core/helpers/integration/undoHarness'\nimport {\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TRIGGER_PATH,\n createScheduleJob,\n deleteScheduleJob,\n} from './helpers/scheduler'\n\n/**\n * TC-UNDO-001 (#2582, refs #2468) \u2014 scheduler.jobs undo/redo correctness.\n *\n * Regression lock for #2504: scheduler.jobs undo was a silent no-op because every\n * undo handler read `logEntry.payload` (always undefined \u2014 the command bus persists\n * the undo snapshot under `commandPayload`), so undo returned `{ ok: true }` while\n * changing nothing. Fixed in PR #2514 via `extractUndoPayload`. This spec asserts the\n * corrected behavior (full restoration), driving the real command bus through the\n * public API + the audit-log undo/redo endpoints.\n *\n * scheduler.jobs is a standard `makeCrudRoute` entity (POST/PUT/DELETE on the\n * collection, `?id=` read-back, camelCase serializer, no custom fields), so the shared\n * `runCrudUndoRoundTrip` harness covers the full round-trip in one pass:\n * - I1 update \u2192 undo restores every scalar (and bumps `updatedAt`)\n * - I2 delete \u2192 undo re-materializes the soft-deleted job\n * - I3 create \u2192 undo soft-deletes (never hard-deletes)\n * - I5 a consumed undo token is rejected on a second undo\n * - I6 redo re-applies the command's after-snapshot\n *\n * I4 (custom fields) is N/A \u2014 `scheduled_job` declares no `ce.ts`, so it has no `cf_*`.\n */\nfunction jobCreatePayload(stamp: string): Record<string, unknown> {\n return {\n name: `TC-UNDO-001 Scheduler ${stamp}`,\n description: 'TC-UNDO-001 undo/redo probe',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n isEnabled: true,\n sourceType: 'user',\n }\n}\n\ntest.describe('TC-UNDO-001 scheduler.jobs undo/redo (#2504)', () => {\n test.beforeAll(() => {\n skipIfUndoTestsDisabled()\n })\n\n test('jobs CRUD commands restore scalar state on undo/redo (I1/I2/I3/I5/I6)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n await runCrudUndoRoundTrip(request, token, {\n label: 'scheduler.jobs',\n collectionPath: SCHEDULER_JOBS_PATH,\n field: 'name',\n createPayload: jobCreatePayload,\n updatePayload: (id, stamp) => ({ id, name: `TC-UNDO-001 Scheduler Renamed ${stamp}` }),\n })\n })\n\n // \u00A74 \u2014 manual trigger is a fire-and-forget enqueue that never goes through the\n // command bus, so it exposes no undo affordance. The assertion holds regardless of\n // QUEUE_STRATEGY: async returns 200 (enqueued), local returns 400 (rejected) \u2014 neither\n // issues an `x-om-operation` undo token.\n test('\u00A74 trigger exposes no undo token', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let jobId: string | null = null\n try {\n jobId = await createScheduleJob(request, token)\n const triggerRes = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token,\n data: { id: jobId },\n })\n expect(extractOperation(triggerRes), 'trigger response carries no undo token (\u00A74)').toBeNull()\n } finally {\n await deleteScheduleJob(request, token, jobId)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAuBP,SAAS,iBAAiB,OAAwC;AAChE,SAAO;AAAA,IACL,MAAM,yBAAyB,KAAK;AAAA,IACpC,aAAa;AAAA,IACb,WAAW;AAAA,IACX,cAAc;AAAA,IACd,eAAe;AAAA,IACf,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,aAAa;AAAA,IACb,WAAW;AAAA,IACX,YAAY;AAAA,EACd;AACF;AAEA,KAAK,SAAS,gDAAgD,MAAM;AAClE,OAAK,UAAU,MAAM;AACnB,4BAAwB;AAAA,EAC1B,CAAC;AAED,OAAK,yEAAyE,OAAO,EAAE,QAAQ,MAAM;AACnG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,qBAAqB,SAAS,OAAO;AAAA,MACzC,OAAO;AAAA,MACP,gBAAgB;AAAA,MAChB,OAAO;AAAA,MACP,eAAe;AAAA,MACf,eAAe,CAAC,IAAI,WAAW,EAAE,IAAI,MAAM,iCAAiC,KAAK,GAAG;AAAA,IACtF,CAAC;AAAA,EACH,CAAC;AAMD,OAAK,uCAAoC,OAAO,EAAE,QAAQ,MAAM;AAC9D,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,QAAuB;AAC3B,QAAI;AACF,cAAQ,MAAM,kBAAkB,SAAS,KAAK;AAC9C,YAAM,aAAa,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC3E;AAAA,QACA,MAAM,EAAE,IAAI,MAAM;AAAA,MACpB,CAAC;AACD,aAAO,iBAAiB,UAAU,GAAG,gDAA6C,EAAE,SAAS;AAAA,IAC/F,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,KAAK;AAAA,IAC/C;AAAA,EACF,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/scheduler",
3
- "version": "0.6.5-develop.5187.1.82e5532561",
3
+ "version": "0.6.5-develop.5212.1.b47932beef",
4
4
  "description": "Database-managed scheduled jobs with admin UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -58,13 +58,13 @@
58
58
  "./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
59
59
  },
60
60
  "dependencies": {
61
- "@open-mercato/events": "0.6.5-develop.5187.1.82e5532561",
62
- "@open-mercato/queue": "0.6.5-develop.5187.1.82e5532561",
61
+ "@open-mercato/events": "0.6.5-develop.5212.1.b47932beef",
62
+ "@open-mercato/queue": "0.6.5-develop.5212.1.b47932beef",
63
63
  "cron-parser": "^5.5.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@mikro-orm/core": "^7.0.14",
67
- "@open-mercato/shared": "0.6.5-develop.5187.1.82e5532561",
67
+ "@open-mercato/shared": "0.6.5-develop.5212.1.b47932beef",
68
68
  "bullmq": "^5.0.0",
69
69
  "ioredis": "^5.0.0",
70
70
  "zod": ">=3.23.0"
@@ -78,7 +78,7 @@
78
78
  }
79
79
  },
80
80
  "devDependencies": {
81
- "@open-mercato/shared": "0.6.5-develop.5187.1.82e5532561",
81
+ "@open-mercato/shared": "0.6.5-develop.5212.1.b47932beef",
82
82
  "@types/jest": "^30.0.0",
83
83
  "@types/node": "^25.9.2",
84
84
  "jest": "^30.4.2",
@@ -1,179 +1,86 @@
1
- import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'
1
+ import { expect, test } from '@playwright/test'
2
2
  import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
- import { skipIfUndoTestsDisabled } from '@open-mercato/core/helpers/integration/undoHarness'
3
+ import {
4
+ extractOperation,
5
+ runCrudUndoRoundTrip,
6
+ skipIfUndoTestsDisabled,
7
+ } from '@open-mercato/core/helpers/integration/undoHarness'
8
+ import {
9
+ SCHEDULER_JOBS_PATH,
10
+ SCHEDULER_TRIGGER_PATH,
11
+ createScheduleJob,
12
+ deleteScheduleJob,
13
+ } from './helpers/scheduler'
4
14
 
5
15
  /**
6
- * TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).
16
+ * TC-UNDO-001 (#2582, refs #2468) — scheduler.jobs undo/redo correctness.
7
17
  *
8
- * Before the fix, every scheduler.jobs undo handler read `logEntry.payload`
9
- * (always undefined — the command bus persists the undo snapshot under
10
- * `commandPayload`), so undo returned `{ ok: true }` while silently changing
11
- * nothing. This spec exercises the real HTTP path end-to-end:
12
- * - CREATE -> undo => job is soft-deleted (no longer listed)
13
- * - UPDATE -> undo => renamed field is restored to its prior value
14
- * - DELETE -> undo => soft-deleted job is re-materialized
18
+ * Regression lock for #2504: scheduler.jobs undo was a silent no-op because every
19
+ * undo handler read `logEntry.payload` (always undefined — the command bus persists
20
+ * the undo snapshot under `commandPayload`), so undo returned `{ ok: true }` while
21
+ * changing nothing. Fixed in PR #2514 via `extractUndoPayload`. This spec asserts the
22
+ * corrected behavior (full restoration), driving the real command bus through the
23
+ * public API + the audit-log undo/redo endpoints.
15
24
  *
16
- * Endpoints covered:
17
- * - POST /api/scheduler/jobs (create)
18
- * - GET /api/scheduler/jobs?id= (read)
19
- * - PUT /api/scheduler/jobs (update)
20
- * - DELETE /api/scheduler/jobs (delete, cleanup)
21
- * - POST /api/audit_logs/audit-logs/actions/undo (undo)
25
+ * scheduler.jobs is a standard `makeCrudRoute` entity (POST/PUT/DELETE on the
26
+ * collection, `?id=` read-back, camelCase serializer, no custom fields), so the shared
27
+ * `runCrudUndoRoundTrip` harness covers the full round-trip in one pass:
28
+ * - I1 update → undo restores every scalar (and bumps `updatedAt`)
29
+ * - I2 delete → undo re-materializes the soft-deleted job
30
+ * - I3 create → undo soft-deletes (never hard-deletes)
31
+ * - I5 a consumed undo token is rejected on a second undo
32
+ * - I6 redo re-applies the command's after-snapshot
33
+ *
34
+ * I4 (custom fields) is N/A — `scheduled_job` declares no `ce.ts`, so it has no `cf_*`.
22
35
  */
23
-
24
- type ScheduleRow = {
25
- id: string
26
- name: string
27
- description: string | null
28
- scheduleValue: string
29
- }
30
-
31
- // The undo endpoint only honors the *latest* undoable log for a resource, and
32
- // `latestUndoableForResource` orders by `created_at` alone. Audit `created_at` is
33
- // millisecond-precision (`new Date()` at log time), so a create + a follow-up
34
- // mutation issued within the same millisecond tie and the lookup may resolve to
35
- // the wrong log. A short settle guarantees the operation under undo is strictly
36
- // the most recent, keeping the round-trip deterministic.
37
- async function settleAuditClock(): Promise<void> {
38
- await new Promise((resolve) => setTimeout(resolve, 50))
39
- }
40
-
41
- function readUndoToken(res: APIResponse): string {
42
- const header = res.headers()['x-om-operation'] ?? ''
43
- const enc = header.startsWith('omop:') ? header.slice(5) : ''
44
- expect(enc, 'x-om-operation header should carry an omop: payload').not.toBe('')
45
- const payload = JSON.parse(decodeURIComponent(enc)) as { undoToken?: string }
46
- expect(typeof payload.undoToken, 'undoToken present in operation payload').toBe('string')
47
- return payload.undoToken as string
48
- }
49
-
50
- async function getById(
51
- request: APIRequestContext,
52
- token: string,
53
- id: string,
54
- ): Promise<ScheduleRow | undefined> {
55
- const res = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token })
56
- expect(res.status()).toBe(200)
57
- const body = (await res.json()) as { items?: ScheduleRow[] }
58
- return (body.items ?? [])[0]
59
- }
60
-
61
- async function createJob(request: APIRequestContext, token: string, name: string): Promise<{ id: string; res: APIResponse }> {
62
- const res = await apiRequest(request, 'POST', '/api/scheduler/jobs', {
63
- token,
64
- data: {
65
- name,
66
- description: 'TC-UNDO-001 probe',
67
- scopeType: 'tenant',
68
- scheduleType: 'cron',
69
- scheduleValue: '0 0 * * *',
70
- timezone: 'UTC',
71
- targetType: 'queue',
72
- targetQueue: 'default',
73
- isEnabled: true,
74
- sourceType: 'user',
75
- },
76
- })
77
- expect(res.status(), 'create returns 201').toBe(201)
78
- const id = ((await res.json()) as { id: string }).id
79
- expect(id, 'create returns an id').toBeTruthy()
80
- return { id, res }
81
- }
82
-
83
- async function undo(request: APIRequestContext, token: string, undoToken: string): Promise<void> {
84
- const res = await apiRequest(request, 'POST', '/api/audit_logs/audit-logs/actions/undo', {
85
- token,
86
- data: { undoToken },
87
- })
88
- const raw = await res.text()
89
- expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200)
90
- const body = JSON.parse(raw) as { ok?: boolean }
91
- expect(body.ok, 'undo body is { ok: true }').toBe(true)
92
- }
93
-
94
- async function cleanup(request: APIRequestContext, token: string | null, id: string | null): Promise<void> {
95
- if (!token || !id) return
96
- try {
97
- await apiRequest(request, 'DELETE', '/api/scheduler/jobs', { token, data: { id } })
98
- } catch {
99
- /* ignore */
36
+ function jobCreatePayload(stamp: string): Record<string, unknown> {
37
+ return {
38
+ name: `TC-UNDO-001 Scheduler ${stamp}`,
39
+ description: 'TC-UNDO-001 undo/redo probe',
40
+ scopeType: 'organization',
41
+ scheduleType: 'interval',
42
+ scheduleValue: '15m',
43
+ timezone: 'UTC',
44
+ targetType: 'queue',
45
+ targetQueue: 'scheduler-execution',
46
+ isEnabled: true,
47
+ sourceType: 'user',
100
48
  }
101
49
  }
102
50
 
103
- test.describe('TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)', () => {
51
+ test.describe('TC-UNDO-001 scheduler.jobs undo/redo (#2504)', () => {
104
52
  test.beforeAll(() => {
105
53
  skipIfUndoTestsDisabled()
106
54
  })
107
55
 
108
- test('UPDATE -> undo restores the prior name', async ({ request }) => {
109
- let token: string | null = null
110
- let id: string | null = null
111
- try {
112
- token = await getAuthToken(request, 'admin')
113
- const originalName = `TC-UNDO-001 Update ${Date.now()}`
114
- ;({ id } = await createJob(request, token, originalName))
115
- await settleAuditClock()
116
-
117
- const updateRes = await apiRequest(request, 'PUT', '/api/scheduler/jobs', {
118
- token,
119
- data: { id, name: `${originalName} RENAMED` },
120
- })
121
- expect(updateRes.status(), 'update returns 200').toBe(200)
122
- expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`)
56
+ test('jobs CRUD commands restore scalar state on undo/redo (I1/I2/I3/I5/I6)', async ({ request }) => {
57
+ const token = await getAuthToken(request, 'admin')
123
58
 
124
- await undo(request, token, readUndoToken(updateRes))
125
-
126
- const restored = await getById(request, token, id)
127
- expect(restored, 'job still exists after update-undo').toBeTruthy()
128
- expect(restored!.name, 'name restored to original by undo').toBe(originalName)
129
- } finally {
130
- await cleanup(request, token, id)
131
- }
59
+ await runCrudUndoRoundTrip(request, token, {
60
+ label: 'scheduler.jobs',
61
+ collectionPath: SCHEDULER_JOBS_PATH,
62
+ field: 'name',
63
+ createPayload: jobCreatePayload,
64
+ updatePayload: (id, stamp) => ({ id, name: `TC-UNDO-001 Scheduler Renamed ${stamp}` }),
65
+ })
132
66
  })
133
67
 
134
- test('DELETE -> undo re-lists the soft-deleted job', async ({ request }) => {
135
- let token: string | null = null
136
- let id: string | null = null
68
+ // §4 manual trigger is a fire-and-forget enqueue that never goes through the
69
+ // command bus, so it exposes no undo affordance. The assertion holds regardless of
70
+ // QUEUE_STRATEGY: async returns 200 (enqueued), local returns 400 (rejected) — neither
71
+ // issues an `x-om-operation` undo token.
72
+ test('§4 trigger exposes no undo token', async ({ request }) => {
73
+ const token = await getAuthToken(request, 'admin')
74
+ let jobId: string | null = null
137
75
  try {
138
- token = await getAuthToken(request, 'admin')
139
- const name = `TC-UNDO-001 Delete ${Date.now()}`
140
- ;({ id } = await createJob(request, token, name))
141
- await settleAuditClock()
142
-
143
- const deleteRes = await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {
76
+ jobId = await createScheduleJob(request, token)
77
+ const triggerRes = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
144
78
  token,
145
- data: { id },
79
+ data: { id: jobId },
146
80
  })
147
- expect(deleteRes.status(), 'delete returns 200').toBe(200)
148
- expect(await getById(request, token, id), 'job is gone after delete').toBeFalsy()
149
-
150
- await undo(request, token, readUndoToken(deleteRes))
151
-
152
- const restored = await getById(request, token, id)
153
- expect(restored, 'job re-materialized by delete-undo').toBeTruthy()
154
- expect(restored!.name).toBe(name)
155
- } finally {
156
- await cleanup(request, token, id)
157
- }
158
- })
159
-
160
- test('CREATE -> undo removes the created job', async ({ request }) => {
161
- let token: string | null = null
162
- let id: string | null = null
163
- try {
164
- token = await getAuthToken(request, 'admin')
165
- const name = `TC-UNDO-001 Create ${Date.now()}`
166
- const created = await createJob(request, token, name)
167
- id = created.id
168
-
169
- expect(await getById(request, token, id), 'job exists after create').toBeTruthy()
170
-
171
- await undo(request, token, readUndoToken(created.res))
172
-
173
- expect(await getById(request, token, id), 'job removed by create-undo').toBeFalsy()
174
- id = null // already undone; nothing to clean up
81
+ expect(extractOperation(triggerRes), 'trigger response carries no undo token (§4)').toBeNull()
175
82
  } finally {
176
- await cleanup(request, token, id)
83
+ await deleteScheduleJob(request, token, jobId)
177
84
  }
178
85
  })
179
86
  })