@open-mercato/scheduler 0.6.8-develop.7065.1.b7b351a126 → 0.6.8-develop.7067.1.82e9867ea1

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.
Files changed (64) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
  4. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +9 -4
  5. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
  6. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
  7. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
  8. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +72 -0
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
  10. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
  11. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  12. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
  13. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  14. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
  15. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +2 -12
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/cli.js +32 -1
  19. package/dist/modules/scheduler/cli.js.map +2 -2
  20. package/dist/modules/scheduler/commands/jobs.js +84 -2
  21. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  22. package/dist/modules/scheduler/data/validators.js +27 -3
  23. package/dist/modules/scheduler/data/validators.js.map +2 -2
  24. package/dist/modules/scheduler/i18n/de.json +2 -0
  25. package/dist/modules/scheduler/i18n/en.json +2 -0
  26. package/dist/modules/scheduler/i18n/es.json +2 -0
  27. package/dist/modules/scheduler/i18n/ko.json +2 -0
  28. package/dist/modules/scheduler/i18n/pl.json +2 -0
  29. package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
  30. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
  31. package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
  32. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  33. package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
  34. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  35. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
  36. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
  37. package/package.json +5 -5
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
  42. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  43. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
  44. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
  46. package/src/modules/scheduler/api/targets/route.ts +6 -17
  47. package/src/modules/scheduler/cli.ts +44 -1
  48. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
  49. package/src/modules/scheduler/commands/jobs.ts +137 -3
  50. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
  52. package/src/modules/scheduler/data/validators.ts +37 -2
  53. package/src/modules/scheduler/i18n/de.json +2 -0
  54. package/src/modules/scheduler/i18n/en.json +2 -0
  55. package/src/modules/scheduler/i18n/es.json +2 -0
  56. package/src/modules/scheduler/i18n/ko.json +2 -0
  57. package/src/modules/scheduler/i18n/pl.json +2 -0
  58. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
  59. package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
  60. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
  61. package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
  62. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
  63. package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
  64. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 57 entry points
1
+ [build:scheduler] found 60 entry points
2
2
  [build:scheduler] built successfully
@@ -11,8 +11,8 @@ async function createSchedule(request, token) {
11
11
  scheduleType: "interval",
12
12
  scheduleValue: "15m",
13
13
  timezone: "UTC",
14
- targetType: "queue",
15
- targetQueue: "scheduler-execution",
14
+ targetType: "command",
15
+ targetCommand: "scheduler.test.echo",
16
16
  targetPayload: { source: "integration-test" },
17
17
  isEnabled: true,
18
18
  sourceType: "user"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype CreateScheduleResponse = { id?: string }\ntype SchedulerListResponse = { items?: Array<{ id?: string }> }\ntype SchedulerErrorResponse = { error?: string; message?: string; available?: boolean; items?: unknown[] }\n\nasync function createSchedule(request: import('@playwright/test').APIRequestContext, token: string) {\n const response = await apiRequest(request, 'POST', '/api/scheduler/jobs', {\n token,\n data: {\n name: `Integration Scheduler ${Date.now()}`,\n description: 'Runtime Redis URL integration probe',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetPayload: { source: 'integration-test' },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status()).toBe(201)\n const body = (await response.json()) as CreateScheduleResponse\n expect(body.id).toMatch(/^[0-9a-f-]{36}$/i)\n return String(body.id)\n}\n\ntest.describe('TC-SCHED-001: Scheduler runtime queue APIs', () => {\n test('lists created schedules and exposes execution-history endpoint semantics', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createSchedule(request, token)\n\n const listResponse = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${scheduleId}`, { token })\n expect(listResponse.status()).toBe(200)\n const listBody = (await listResponse.json()) as SchedulerListResponse\n const ids = (listBody.items ?? []).map((item) => item.id)\n expect(ids).toContain(scheduleId)\n\n const executionsResponse = await apiRequest(\n request,\n 'GET',\n `/api/scheduler/jobs/${scheduleId}/executions?pageSize=5`,\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(executionsResponse.status()).toBe(200)\n const executionsBody = (await executionsResponse.json()) as { items?: unknown[]; total?: number; pageSize?: number }\n expect(Array.isArray(executionsBody.items)).toBe(true)\n expect(typeof executionsBody.total).toBe('number')\n expect(executionsBody.pageSize).toBe(5)\n } else {\n expect(executionsResponse.status()).toBe(400)\n const executionsBody = (await executionsResponse.json()) as SchedulerErrorResponse\n expect(executionsBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|Execution history requires/i)\n expect(Array.isArray(executionsBody.items)).toBe(true)\n }\n } finally {\n if (scheduleId) {\n await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {\n token,\n data: { id: scheduleId },\n }).catch(() => null)\n }\n }\n })\n\n test('validates queue names and async-only queue job access', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const invalidQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=not-a-registered-queue',\n { token },\n )\n expect(invalidQueueResponse.status()).toBe(400)\n const invalidQueueBody = (await invalidQueueResponse.json()) as SchedulerErrorResponse\n expect(invalidQueueBody.error ?? '').toMatch(/Invalid queue name/i)\n\n const validQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=scheduler-execution',\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(validQueueResponse.status()).toBe(404)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/Job not found/i)\n } else {\n expect(validQueueResponse.status()).toBe(400)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|BullMQ job logs are only available/i)\n expect(validQueueBody.available).toBe(false)\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/modules/core/__integration__/helpers/api'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype CreateScheduleResponse = { id?: string }\ntype SchedulerListResponse = { items?: Array<{ id?: string }> }\ntype SchedulerErrorResponse = { error?: string; message?: string; available?: boolean; items?: unknown[] }\n\nasync function createSchedule(request: import('@playwright/test').APIRequestContext, token: string) {\n const response = await apiRequest(request, 'POST', '/api/scheduler/jobs', {\n token,\n data: {\n name: `Integration Scheduler ${Date.now()}`,\n description: 'Runtime Redis URL integration probe',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'command',\n targetCommand: 'scheduler.test.echo',\n targetPayload: { source: 'integration-test' },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status()).toBe(201)\n const body = (await response.json()) as CreateScheduleResponse\n expect(body.id).toMatch(/^[0-9a-f-]{36}$/i)\n return String(body.id)\n}\n\ntest.describe('TC-SCHED-001: Scheduler runtime queue APIs', () => {\n test('lists created schedules and exposes execution-history endpoint semantics', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createSchedule(request, token)\n\n const listResponse = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${scheduleId}`, { token })\n expect(listResponse.status()).toBe(200)\n const listBody = (await listResponse.json()) as SchedulerListResponse\n const ids = (listBody.items ?? []).map((item) => item.id)\n expect(ids).toContain(scheduleId)\n\n const executionsResponse = await apiRequest(\n request,\n 'GET',\n `/api/scheduler/jobs/${scheduleId}/executions?pageSize=5`,\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(executionsResponse.status()).toBe(200)\n const executionsBody = (await executionsResponse.json()) as { items?: unknown[]; total?: number; pageSize?: number }\n expect(Array.isArray(executionsBody.items)).toBe(true)\n expect(typeof executionsBody.total).toBe('number')\n expect(executionsBody.pageSize).toBe(5)\n } else {\n expect(executionsResponse.status()).toBe(400)\n const executionsBody = (await executionsResponse.json()) as SchedulerErrorResponse\n expect(executionsBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|Execution history requires/i)\n expect(Array.isArray(executionsBody.items)).toBe(true)\n }\n } finally {\n if (scheduleId) {\n await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {\n token,\n data: { id: scheduleId },\n }).catch(() => null)\n }\n }\n })\n\n test('validates queue names and async-only queue job access', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const invalidQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=not-a-registered-queue',\n { token },\n )\n expect(invalidQueueResponse.status()).toBe(400)\n const invalidQueueBody = (await invalidQueueResponse.json()) as SchedulerErrorResponse\n expect(invalidQueueBody.error ?? '').toMatch(/Invalid queue name/i)\n\n const validQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=scheduler-execution',\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(validQueueResponse.status()).toBe(404)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/Job not found/i)\n } else {\n expect(validQueueResponse.status()).toBe(400)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|BullMQ job logs are only available/i)\n expect(validQueueBody.available).toBe(false)\n }\n })\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,10 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
3
  import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
- import { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from "./helpers/scheduler.js";
4
+ import {
5
+ SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
+ SCHEDULER_TARGETS_PATH
7
+ } from "./helpers/scheduler.js";
5
8
  function expectSortedByValue(options, label) {
6
9
  const values = options.map((option) => option.value);
7
10
  expect(values, `${label} should be sorted alphabetically by value`).toEqual(
@@ -15,7 +18,7 @@ function expectOptionShape(options, label) {
15
18
  }
16
19
  }
17
20
  test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and commands", () => {
18
- test("returns sorted queues (incl. scheduler-execution) and a non-empty command list", async ({ request }) => {
21
+ test("returns only scheduler-safe queues plus a non-empty sorted command list", async ({ request }) => {
19
22
  const token = await getAuthToken(request, "admin");
20
23
  const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
21
24
  expect(response.status()).toBe(200);
@@ -23,10 +26,12 @@ test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and command
23
26
  const queues = body?.queues ?? [];
24
27
  const commands = body?.commands ?? [];
25
28
  expect(Array.isArray(body?.queues)).toBe(true);
26
- expect(queues.length).toBeGreaterThan(0);
27
29
  expectOptionShape(queues, "queues");
28
- expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE);
29
30
  expectSortedByValue(queues, "queues");
31
+ const queueValues = queues.map((queue) => queue.value);
32
+ for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
33
+ expect(queueValues, `internal queue ${forbidden} must not be a scheduler target`).not.toContain(forbidden);
34
+ }
30
35
  expect(Array.isArray(body?.commands)).toBe(true);
31
36
  expect(commands.length).toBeGreaterThan(0);
32
37
  expectOptionShape(commands, "commands");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from './helpers/scheduler'\n\ntype TargetOption = { value: string; label: string }\ntype TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }\n\nfunction expectSortedByValue(options: TargetOption[], label: string) {\n const values = options.map((option) => option.value)\n expect(values, `${label} should be sorted alphabetically by value`).toEqual(\n [...values].sort((a, b) => a.localeCompare(b)),\n )\n}\n\nfunction expectOptionShape(options: TargetOption[], label: string) {\n for (const option of options) {\n expect(typeof option.value, `${label} option value should be a string`).toBe('string')\n expect(typeof option.label, `${label} option label should be a string`).toBe('string')\n }\n}\n\n/**\n * TC-SCHED-004: GET /api/scheduler/targets returns available queue names\n * (from module workers) and registered command IDs (from the command\n * registry), both sorted alphabetically by value. Read-only, no fixtures.\n */\ntest.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {\n test('returns sorted queues (incl. scheduler-execution) and a non-empty command list', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<TargetsResponse>(response)\n const queues = body?.queues ?? []\n const commands = body?.commands ?? []\n\n // Queues: non-empty, well-shaped, sorted, and include the known scheduler queue.\n expect(Array.isArray(body?.queues)).toBe(true)\n expect(queues.length).toBeGreaterThan(0)\n expectOptionShape(queues, 'queues')\n expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE)\n expectSortedByValue(queues, 'queues')\n\n // Commands: non-empty, well-shaped, and sorted.\n expect(Array.isArray(body?.commands)).toBe(true)\n expect(commands.length).toBeGreaterThan(0)\n expectOptionShape(commands, 'commands')\n expectSortedByValue(commands, 'commands')\n })\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B,8BAA8B;AAKlE,SAAS,oBAAoB,SAAyB,OAAe;AACnE,QAAM,SAAS,QAAQ,IAAI,CAAC,WAAW,OAAO,KAAK;AACnD,SAAO,QAAQ,GAAG,KAAK,2CAA2C,EAAE;AAAA,IAClE,CAAC,GAAG,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EAC/C;AACF;AAEA,SAAS,kBAAkB,SAAyB,OAAe;AACjE,aAAW,UAAU,SAAS;AAC5B,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AACrF,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AAAA,EACvF;AACF;AAOA,KAAK,SAAS,sEAAsE,MAAM;AACxF,OAAK,kFAAkF,OAAO,EAAE,QAAQ,MAAM;AAC5G,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,UAAM,SAAS,MAAM,UAAU,CAAC;AAChC,UAAM,WAAW,MAAM,YAAY,CAAC;AAGpC,WAAO,MAAM,QAAQ,MAAM,MAAM,CAAC,EAAE,KAAK,IAAI;AAC7C,WAAO,OAAO,MAAM,EAAE,gBAAgB,CAAC;AACvC,sBAAkB,QAAQ,QAAQ;AAClC,WAAO,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAAE,UAAU,yBAAyB;AAC9E,wBAAoB,QAAQ,QAAQ;AAGpC,WAAO,MAAM,QAAQ,MAAM,QAAQ,CAAC,EAAE,KAAK,IAAI;AAC/C,WAAO,SAAS,MAAM,EAAE,gBAAgB,CAAC;AACzC,sBAAkB,UAAU,UAAU;AACtC,wBAAoB,UAAU,UAAU;AAAA,EAC1C,CAAC;AACH,CAAC;",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,\n SCHEDULER_TARGETS_PATH,\n} from './helpers/scheduler'\n\ntype TargetOption = { value: string; label: string }\ntype TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }\n\nfunction expectSortedByValue(options: TargetOption[], label: string) {\n const values = options.map((option) => option.value)\n expect(values, `${label} should be sorted alphabetically by value`).toEqual(\n [...values].sort((a, b) => a.localeCompare(b)),\n )\n}\n\nfunction expectOptionShape(options: TargetOption[], label: string) {\n for (const option of options) {\n expect(typeof option.value, `${label} option value should be a string`).toBe('string')\n expect(typeof option.label, `${label} option label should be a string`).toBe('string')\n }\n}\n\n/**\n * TC-SCHED-004: GET /api/scheduler/targets returns scheduler-safe queue names\n * and registered command IDs (from the command registry), both sorted\n * alphabetically by value. Internal/system workers must stay undiscoverable.\n * Read-only, no fixtures.\n */\ntest.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {\n test('returns only scheduler-safe queues plus a non-empty sorted command list', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<TargetsResponse>(response)\n const queues = body?.queues ?? []\n const commands = body?.commands ?? []\n\n // Queues: well-shaped, sorted, and free of internal/system worker queues (#5213).\n expect(Array.isArray(body?.queues)).toBe(true)\n expectOptionShape(queues, 'queues')\n expectSortedByValue(queues, 'queues')\n const queueValues = queues.map((queue) => queue.value)\n for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n expect(queueValues, `internal queue ${forbidden} must not be a scheduler target`).not.toContain(forbidden)\n }\n\n // Commands: non-empty, well-shaped, and sorted.\n expect(Array.isArray(body?.commands)).toBe(true)\n expect(commands.length).toBeGreaterThan(0)\n expectOptionShape(commands, 'commands')\n expectSortedByValue(commands, 'commands')\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAKP,SAAS,oBAAoB,SAAyB,OAAe;AACnE,QAAM,SAAS,QAAQ,IAAI,CAAC,WAAW,OAAO,KAAK;AACnD,SAAO,QAAQ,GAAG,KAAK,2CAA2C,EAAE;AAAA,IAClE,CAAC,GAAG,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EAC/C;AACF;AAEA,SAAS,kBAAkB,SAAyB,OAAe;AACjE,aAAW,UAAU,SAAS;AAC5B,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AACrF,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AAAA,EACvF;AACF;AAQA,KAAK,SAAS,sEAAsE,MAAM;AACxF,OAAK,2EAA2E,OAAO,EAAE,QAAQ,MAAM;AACrG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,UAAM,SAAS,MAAM,UAAU,CAAC;AAChC,UAAM,WAAW,MAAM,YAAY,CAAC;AAGpC,WAAO,MAAM,QAAQ,MAAM,MAAM,CAAC,EAAE,KAAK,IAAI;AAC7C,sBAAkB,QAAQ,QAAQ;AAClC,wBAAoB,QAAQ,QAAQ;AACpC,UAAM,cAAc,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK;AACrD,eAAW,aAAa,qCAAqC;AAC3D,aAAO,aAAa,kBAAkB,SAAS,iCAAiC,EAAE,IAAI,UAAU,SAAS;AAAA,IAC3G;AAGA,WAAO,MAAM,QAAQ,MAAM,QAAQ,CAAC,EAAE,KAAK,IAAI;AAC/C,WAAO,SAAS,MAAM,EAAE,gBAAgB,CAAC;AACzC,sBAAkB,UAAU,UAAU;AACtC,wBAAoB,UAAU,UAAU;AAAA,EAC1C,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
3
  import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
- import { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from "./helpers/scheduler.js";
4
+ import { SCHEDULER_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from "./helpers/scheduler.js";
5
5
  const invalidCases = [
6
6
  { label: "cron: not a cron expression", scheduleType: "cron", scheduleValue: "not-a-cron" },
7
7
  { label: "cron: too few fields", scheduleType: "cron", scheduleValue: "* * *" },
@@ -23,8 +23,8 @@ test.describe("TC-SCHED-007: POST /api/scheduler/jobs validates schedule value f
23
23
  scheduleType: testCase.scheduleType,
24
24
  scheduleValue: testCase.scheduleValue,
25
25
  timezone: "UTC",
26
- targetType: "queue",
27
- targetQueue: SCHEDULER_EXECUTION_QUEUE,
26
+ targetType: "command",
27
+ targetCommand: SCHEDULER_TEST_COMMAND,
28
28
  isEnabled: true,
29
29
  sourceType: "user"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from './helpers/scheduler'\n\ntype ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }\n\n/**\n * TC-SCHED-007: POST /api/scheduler/jobs rejects invalid cron and interval\n * `scheduleValue`s with 400 and an error scoped to the `scheduleValue` path.\n *\n * Interval format is `<number><unit>` with unit in s|m|h|d and a minimum floor\n * of one minute. All cases are rejected at validation, so no records are\n * created and no teardown is required.\n */\nconst invalidCases = [\n { label: 'cron: not a cron expression', scheduleType: 'cron' as const, scheduleValue: 'not-a-cron' },\n { label: 'cron: too few fields', scheduleType: 'cron' as const, scheduleValue: '* * *' },\n { label: 'interval: unsupported unit', scheduleType: 'interval' as const, scheduleValue: '15x' },\n { label: 'interval: missing unit', scheduleType: 'interval' as const, scheduleValue: '99' },\n { label: 'interval: zero seconds', scheduleType: 'interval' as const, scheduleValue: '0s' },\n { label: 'interval: one second', scheduleType: 'interval' as const, scheduleValue: '1s' },\n { label: 'interval: below one minute', scheduleType: 'interval' as const, scheduleValue: '59s' },\n]\n\ntest.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value format', () => {\n for (const testCase of invalidCases) {\n test(`rejects ${testCase.label} with 400 on scheduleValue`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Invalid Schedule Value'),\n scopeType: 'organization',\n scheduleType: testCase.scheduleType,\n scheduleValue: testCase.scheduleValue,\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: SCHEDULER_EXECUTION_QUEUE,\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${testCase.label} should be rejected with 400`).toBe(400)\n const body = await readJsonSafe<ValidationError>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths, `${testCase.label} error should target scheduleValue`).toContain('scheduleValue')\n })\n }\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B,qBAAqB,0BAA0B;AAYnF,MAAM,eAAe;AAAA,EACnB,EAAE,OAAO,+BAA+B,cAAc,QAAiB,eAAe,aAAa;AAAA,EACnG,EAAE,OAAO,wBAAwB,cAAc,QAAiB,eAAe,QAAQ;AAAA,EACvF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AAAA,EAC/F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,wBAAwB,cAAc,YAAqB,eAAe,KAAK;AAAA,EACxF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AACjG;AAEA,KAAK,SAAS,0EAA0E,MAAM;AAC5F,aAAW,YAAY,cAAc;AACnC,SAAK,WAAW,SAAS,KAAK,8BAA8B,OAAO,EAAE,QAAQ,MAAM;AACjF,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc,SAAS;AAAA,UACvB,eAAe,SAAS;AAAA,UACxB,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,SAAS,KAAK,8BAA8B,EAAE,KAAK,GAAG;AACnF,YAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,YAAY,GAAG,SAAS,KAAK,oCAAoC,EAAE,UAAU,eAAe;AAAA,IACrG,CAAC;AAAA,EACH;AACF,CAAC;",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from './helpers/scheduler'\n\ntype ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }\n\n/**\n * TC-SCHED-007: POST /api/scheduler/jobs rejects invalid cron and interval\n * `scheduleValue`s with 400 and an error scoped to the `scheduleValue` path.\n *\n * Interval format is `<number><unit>` with unit in s|m|h|d and a minimum floor\n * of one minute. All cases are rejected at validation, so no records are\n * created and no teardown is required.\n */\nconst invalidCases = [\n { label: 'cron: not a cron expression', scheduleType: 'cron' as const, scheduleValue: 'not-a-cron' },\n { label: 'cron: too few fields', scheduleType: 'cron' as const, scheduleValue: '* * *' },\n { label: 'interval: unsupported unit', scheduleType: 'interval' as const, scheduleValue: '15x' },\n { label: 'interval: missing unit', scheduleType: 'interval' as const, scheduleValue: '99' },\n { label: 'interval: zero seconds', scheduleType: 'interval' as const, scheduleValue: '0s' },\n { label: 'interval: one second', scheduleType: 'interval' as const, scheduleValue: '1s' },\n { label: 'interval: below one minute', scheduleType: 'interval' as const, scheduleValue: '59s' },\n]\n\ntest.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value format', () => {\n for (const testCase of invalidCases) {\n test(`rejects ${testCase.label} with 400 on scheduleValue`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Invalid Schedule Value'),\n scopeType: 'organization',\n scheduleType: testCase.scheduleType,\n scheduleValue: testCase.scheduleValue,\n timezone: 'UTC',\n targetType: 'command',\n targetCommand: SCHEDULER_TEST_COMMAND,\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${testCase.label} should be rejected with 400`).toBe(400)\n const body = await readJsonSafe<ValidationError>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths, `${testCase.label} error should target scheduleValue`).toContain('scheduleValue')\n })\n }\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB,wBAAwB,0BAA0B;AAYhF,MAAM,eAAe;AAAA,EACnB,EAAE,OAAO,+BAA+B,cAAc,QAAiB,eAAe,aAAa;AAAA,EACnG,EAAE,OAAO,wBAAwB,cAAc,QAAiB,eAAe,QAAQ;AAAA,EACvF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AAAA,EAC/F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,wBAAwB,cAAc,YAAqB,eAAe,KAAK;AAAA,EACxF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AACjG;AAEA,KAAK,SAAS,0EAA0E,MAAM;AAC5F,aAAW,YAAY,cAAc;AACnC,SAAK,WAAW,SAAS,KAAK,8BAA8B,OAAO,EAAE,QAAQ,MAAM;AACjF,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc,SAAS;AAAA,UACvB,eAAe,SAAS;AAAA,UACxB,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,eAAe;AAAA,UACf,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,SAAS,KAAK,8BAA8B,EAAE,KAAK,GAAG;AACnF,YAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,YAAY,GAAG,SAAS,KAAK,oCAAoC,EAAE,UAAU,eAAe;AAAA,IACrG,CAAC;AAAA,EACH;AACF,CAAC;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,72 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import {
5
+ SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
+ SCHEDULER_JOBS_PATH,
7
+ SCHEDULER_TARGETS_PATH,
8
+ createScheduleJob,
9
+ deleteScheduleJob,
10
+ getScheduleJobById,
11
+ uniqueScheduleName
12
+ } from "./helpers/scheduler.js";
13
+ test.describe("TC-SCHED-008: scheduler queue targets restricted to safe workers", () => {
14
+ for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
15
+ test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {
16
+ const token = await getAuthToken(request, "admin");
17
+ const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, {
18
+ token,
19
+ data: {
20
+ name: uniqueScheduleName("Forbidden Queue Target"),
21
+ scopeType: "organization",
22
+ scheduleType: "interval",
23
+ scheduleValue: "15m",
24
+ timezone: "UTC",
25
+ targetType: "queue",
26
+ targetQueue: forbiddenQueue,
27
+ targetPayload: { scope: { tenantId: "forged", organizationId: "forged" } },
28
+ isEnabled: true,
29
+ sourceType: "user"
30
+ }
31
+ });
32
+ expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400);
33
+ const body = await readJsonSafe(response);
34
+ const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join("."));
35
+ expect(issuePaths).toContain("targetQueue");
36
+ });
37
+ }
38
+ test("rejects retargeting an existing schedule to an internal worker queue", async ({ request }) => {
39
+ const token = await getAuthToken(request, "admin");
40
+ let scheduleId = null;
41
+ try {
42
+ scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName("Retarget Guard") });
43
+ const response = await apiRequest(request, "PUT", SCHEDULER_JOBS_PATH, {
44
+ token,
45
+ data: {
46
+ id: scheduleId,
47
+ targetType: "queue",
48
+ targetQueue: "stripe-webhook"
49
+ }
50
+ });
51
+ expect(response.status()).toBe(422);
52
+ const body = await readJsonSafe(response);
53
+ expect(body?.error ?? "").toMatch(/not an approved scheduler target/i);
54
+ const unchanged = await getScheduleJobById(request, token, scheduleId);
55
+ expect(unchanged).not.toBeNull();
56
+ expect(unchanged.targetType).toBe("command");
57
+ } finally {
58
+ await deleteScheduleJob(request, token, scheduleId);
59
+ }
60
+ });
61
+ test("targets endpoint keeps internal worker queues undiscoverable", async ({ request }) => {
62
+ const token = await getAuthToken(request, "admin");
63
+ const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
64
+ expect(response.status()).toBe(200);
65
+ const body = await readJsonSafe(response);
66
+ const values = (body?.queues ?? []).map((queue) => queue.value);
67
+ for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
68
+ expect(values).not.toContain(forbidden);
69
+ }
70
+ });
71
+ });
72
+ //# sourceMappingURL=TC-SCHED-008-safe-targets.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TARGETS_PATH,\n createScheduleJob,\n deleteScheduleJob,\n getScheduleJobById,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\n/**\n * TC-SCHED-008: Scheduler queue targets are restricted to authorized safe\n * workers (#5213). A principal holding only `scheduler.jobs.manage` must not be\n * able to aim a schedule at an internal/system worker queue with an arbitrary\n * payload \u2014 neither at creation, nor by retargeting later.\n */\ntest.describe('TC-SCHED-008: scheduler queue targets restricted to safe workers', () => {\n for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Forbidden Queue Target'),\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: forbiddenQueue,\n targetPayload: { scope: { tenantId: 'forged', organizationId: 'forged' } },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400)\n const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number> }> }>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths).toContain('targetQueue')\n })\n }\n\n test('rejects retargeting an existing schedule to an internal worker queue', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName('Retarget Guard') })\n\n const response = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n id: scheduleId,\n targetType: 'queue',\n targetQueue: 'stripe-webhook',\n },\n })\n\n // Enforcement lives in the update command (change-detection vs the stored\n // row), so an actual retarget lands as 422 with the approval error.\n expect(response.status()).toBe(422)\n const body = await readJsonSafe<{ error?: string }>(response)\n expect(body?.error ?? '').toMatch(/not an approved scheduler target/i)\n\n const unchanged = await getScheduleJobById(request, token, scheduleId)\n expect(unchanged).not.toBeNull()\n expect(unchanged!.targetType).toBe('command')\n } finally {\n await deleteScheduleJob(request, token, scheduleId)\n }\n })\n\n test('targets endpoint keeps internal worker queues undiscoverable', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<{ queues?: Array<{ value: string }> }>(response)\n const values = (body?.queues ?? []).map((queue) => queue.value)\n for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n expect(values).not.toContain(forbidden)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,KAAK,SAAS,oEAAoE,MAAM;AACtF,aAAW,kBAAkB,qCAAqC;AAChE,SAAK,6CAA6C,cAAc,WAAW,OAAO,EAAE,QAAQ,MAAM;AAChG,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe,EAAE,OAAO,EAAE,UAAU,UAAU,gBAAgB,SAAS,EAAE;AAAA,UACzE,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,cAAc,0BAA0B,EAAE,KAAK,GAAG;AAC/E,YAAM,OAAO,MAAM,aAAqF,QAAQ;AAChH,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,UAAU,EAAE,UAAU,aAAa;AAAA,IAC5C,CAAC;AAAA,EACH;AAEA,OAAK,wEAAwE,OAAO,EAAE,QAAQ,MAAM;AAClG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAEhC,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,OAAO,EAAE,MAAM,mBAAmB,gBAAgB,EAAE,CAAC;AAEnG,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,qBAAqB;AAAA,QACrE;AAAA,QACA,MAAM;AAAA,UACJ,IAAI;AAAA,UACJ,YAAY;AAAA,UACZ,aAAa;AAAA,QACf;AAAA,MACF,CAAC;AAID,aAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,YAAM,OAAO,MAAM,aAAiC,QAAQ;AAC5D,aAAO,MAAM,SAAS,EAAE,EAAE,QAAQ,mCAAmC;AAErE,YAAM,YAAY,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACrE,aAAO,SAAS,EAAE,IAAI,SAAS;AAC/B,aAAO,UAAW,UAAU,EAAE,KAAK,SAAS;AAAA,IAC9C,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IACpD;AAAA,EACF,CAAC;AAED,OAAK,gEAAgE,OAAO,EAAE,QAAQ,MAAM;AAC1F,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAAoD,QAAQ;AAC/E,UAAM,UAAU,MAAM,UAAU,CAAC,GAAG,IAAI,CAAC,UAAU,MAAM,KAAK;AAC9D,eAAW,aAAa,qCAAqC;AAC3D,aAAO,MAAM,EAAE,IAAI,UAAU,SAAS;AAAA,IACxC;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -50,7 +50,7 @@ test.describe("TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
50
50
  scheduleValue: "15m",
51
51
  timezone: "UTC",
52
52
  targetType: "queue",
53
- targetQueue: "scheduler-execution",
53
+ targetQueue: "scheduler-test",
54
54
  targetPayload: createPayload,
55
55
  isEnabled: true,
56
56
  sourceType: "user"
@@ -65,7 +65,7 @@ test.describe("TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
65
65
  scheduleValue: "15m",
66
66
  timezone: "UTC",
67
67
  targetType: "queue",
68
- targetQueue: "scheduler-execution",
68
+ targetQueue: "scheduler-test",
69
69
  targetCommand: null,
70
70
  targetPayload: createPayload,
71
71
  isEnabled: true,
@@ -93,7 +93,7 @@ test.describe("TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
93
93
  scheduleValue: "0 0 * * *",
94
94
  timezone: "Europe/Warsaw",
95
95
  targetType: "queue",
96
- targetQueue: "scheduler-execution",
96
+ targetQueue: "scheduler-test",
97
97
  targetCommand: null,
98
98
  targetPayload: updatePayload,
99
99
  isEnabled: false
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts"],
4
- "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n runCrudFormRoundTrip,\n skipIfCrudFormExtensionTestsDisabled,\n type CrudRecord,\n} from '@open-mercato/core/helpers/integration/crudFormPersistence';\n\n/**\n * TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON (#2466).\n *\n * The scheduler's scheduled-job is the Tier-B \"JSON-bearing scalar\" surface: scope\n * (scopeType, server-derived org/tenant), schedule (scheduleType/scheduleValue/timezone),\n * target (targetType/targetQueue), and a nested `targetPayload` JSON object. Proves create +\n * update round-trip every value.\n *\n * Verified contract (differs from the makeCrud-default reference specs):\n * - The list GET filters by `?id=` (single uuid), not the `?ids=` other sweep modules use; the\n * explicit `readById` documents that contract and mirrors the sibling specs (the shared\n * harness default reads back via `?id=` too).\n * - Read-back is camelCase: the list `transformItem` maps every column to camelCase\n * (`scopeType`, `scheduleType`, `targetPayload`, `isEnabled`, ...), so the scalar\n * expectations use camelCase keys (not the snake_case of resources/staff).\n * - The scheduled-job declares no custom entity (no `ce.ts`), so there are no `cf_*` fields \u2014\n * the surface is scalars + nested JSON only.\n * - Scope is derived server-side from the caller's auth context and is immutable on update:\n * `scopeType: 'organization'` fills org+tenant from the admin token and survives the edit.\n * - Update is a partial PUT: omitted target fields are retained, so the update changes the\n * payload JSON while proving `targetType`/`targetQueue` survive untouched.\n * - Harness cleanup deletes via `?id=`; the scheduler delete resolves the id from the query\n * string, so the default `finally` cleanup removes the fixture.\n *\n * Self-contained: the job needs no pre-created fixtures (queue target + inline JSON), and the\n * harness deletes it in `finally`.\n *\n * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off \u2192 runs).\n */\nconst SCHEDULER_JOBS_PATH = '/api/scheduler/jobs';\n\nasync function readScheduledJobById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<CrudRecord | null> {\n const response = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}&page=1&pageSize=100`,\n { token },\n );\n expect(response.status(), `read-back scheduler jobs failed: ${response.status()}`).toBe(200);\n const body = await readJsonSafe<{ items?: CrudRecord[] }>(response);\n return (body?.items ?? []).find((item) => item.id === id) ?? null;\n}\n\ntest.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON', () => {\n test.beforeAll(() => {\n skipIfCrudFormExtensionTestsDisabled();\n });\n\n test('round-trips scalars, scope, target + nested payload JSON on create and update', async ({ request }) => {\n const token = await getAuthToken(request, 'admin');\n const stamp = Date.now();\n\n const createPayload = {\n source: 'integration-test',\n attempts: 3,\n retries: { max: 5, backoff: 'exponential' },\n tags: ['alpha', 'beta'],\n };\n const updatePayload = {\n source: 'integration-test-edited',\n attempts: 1,\n retries: { max: 0, backoff: 'fixed' },\n tags: ['gamma'],\n };\n\n await runCrudFormRoundTrip({\n request,\n token,\n collectionPath: SCHEDULER_JOBS_PATH,\n readById: (id) => readScheduledJobById(request, token, id),\n create: {\n payload: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n expectAfterCreate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetCommand: null,\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n update: {\n payload: (id) => ({\n id,\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetPayload: updatePayload,\n isEnabled: false,\n }),\n },\n expectAfterUpdate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scopeType: 'organization',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetCommand: null,\n targetPayload: updatePayload,\n isEnabled: false,\n },\n },\n });\n });\n});\n"],
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n runCrudFormRoundTrip,\n skipIfCrudFormExtensionTestsDisabled,\n type CrudRecord,\n} from '@open-mercato/core/helpers/integration/crudFormPersistence';\n\n/**\n * TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON (#2466).\n *\n * The scheduler's scheduled-job is the Tier-B \"JSON-bearing scalar\" surface: scope\n * (scopeType, server-derived org/tenant), schedule (scheduleType/scheduleValue/timezone),\n * target (targetType/targetQueue), and a nested `targetPayload` JSON object. Proves create +\n * update round-trip every value.\n *\n * Verified contract (differs from the makeCrud-default reference specs):\n * - The list GET filters by `?id=` (single uuid), not the `?ids=` other sweep modules use; the\n * explicit `readById` documents that contract and mirrors the sibling specs (the shared\n * harness default reads back via `?id=` too).\n * - Read-back is camelCase: the list `transformItem` maps every column to camelCase\n * (`scopeType`, `scheduleType`, `targetPayload`, `isEnabled`, ...), so the scalar\n * expectations use camelCase keys (not the snake_case of resources/staff).\n * - The scheduled-job declares no custom entity (no `ce.ts`), so there are no `cf_*` fields \u2014\n * the surface is scalars + nested JSON only.\n * - Scope is derived server-side from the caller's auth context and is immutable on update:\n * `scopeType: 'organization'` fills org+tenant from the admin token and survives the edit.\n * - Update is a partial PUT: omitted target fields are retained, so the update changes the\n * payload JSON while proving `targetType`/`targetQueue` survive untouched.\n * - Harness cleanup deletes via `?id=`; the scheduler delete resolves the id from the query\n * string, so the default `finally` cleanup removes the fixture.\n *\n * Self-contained: the job needs no pre-created fixtures (queue target + inline JSON), and the\n * harness deletes it in `finally`.\n *\n * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off \u2192 runs).\n */\nconst SCHEDULER_JOBS_PATH = '/api/scheduler/jobs';\n\nasync function readScheduledJobById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<CrudRecord | null> {\n const response = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}&page=1&pageSize=100`,\n { token },\n );\n expect(response.status(), `read-back scheduler jobs failed: ${response.status()}`).toBe(200);\n const body = await readJsonSafe<{ items?: CrudRecord[] }>(response);\n return (body?.items ?? []).find((item) => item.id === id) ?? null;\n}\n\ntest.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON', () => {\n test.beforeAll(() => {\n skipIfCrudFormExtensionTestsDisabled();\n });\n\n test('round-trips scalars, scope, target + nested payload JSON on create and update', async ({ request }) => {\n const token = await getAuthToken(request, 'admin');\n const stamp = Date.now();\n\n const createPayload = {\n source: 'integration-test',\n attempts: 3,\n retries: { max: 5, backoff: 'exponential' },\n tags: ['alpha', 'beta'],\n };\n const updatePayload = {\n source: 'integration-test-edited',\n attempts: 1,\n retries: { max: 0, backoff: 'fixed' },\n tags: ['gamma'],\n };\n\n await runCrudFormRoundTrip({\n request,\n token,\n collectionPath: SCHEDULER_JOBS_PATH,\n readById: (id) => readScheduledJobById(request, token, id),\n create: {\n payload: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-test',\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n expectAfterCreate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-test',\n targetCommand: null,\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n update: {\n payload: (id) => ({\n id,\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetPayload: updatePayload,\n isEnabled: false,\n }),\n },\n expectAfterUpdate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scopeType: 'organization',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetType: 'queue',\n targetQueue: 'scheduler-test',\n targetCommand: null,\n targetPayload: updatePayload,\n isEnabled: false,\n },\n },\n });\n });\n});\n"],
5
5
  "mappings": "AAAA,SAAS,QAAQ,YAAoC;AACrD,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AA+BP,MAAM,sBAAsB;AAE5B,eAAe,qBACb,SACA,OACA,IAC4B;AAC5B,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA,GAAG,mBAAmB,OAAO,mBAAmB,EAAE,CAAC;AAAA,IACnD,EAAE,MAAM;AAAA,EACV;AACA,SAAO,SAAS,OAAO,GAAG,oCAAoC,SAAS,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AAC3F,QAAM,OAAO,MAAM,aAAuC,QAAQ;AAClE,UAAQ,MAAM,SAAS,CAAC,GAAG,KAAK,CAAC,SAAS,KAAK,OAAO,EAAE,KAAK;AAC/D;AAEA,KAAK,SAAS,uFAAuF,MAAM;AACzG,OAAK,UAAU,MAAM;AACnB,yCAAqC;AAAA,EACvC,CAAC;AAED,OAAK,iFAAiF,OAAO,EAAE,QAAQ,MAAM;AAC3G,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,QAAQ,KAAK,IAAI;AAEvB,UAAM,gBAAgB;AAAA,MACpB,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,SAAS,EAAE,KAAK,GAAG,SAAS,cAAc;AAAA,MAC1C,MAAM,CAAC,SAAS,MAAM;AAAA,IACxB;AACA,UAAM,gBAAgB;AAAA,MACpB,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,SAAS,EAAE,KAAK,GAAG,SAAS,QAAQ;AAAA,MACpC,MAAM,CAAC,OAAO;AAAA,IAChB;AAEA,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,gBAAgB;AAAA,MAChB,UAAU,CAAC,OAAO,qBAAqB,SAAS,OAAO,EAAE;AAAA,MACzD,QAAQ;AAAA,QACN,SAAS;AAAA,UACP,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe;AAAA,UACf,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe;AAAA,UACf,eAAe;AAAA,UACf,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,SAAS,CAAC,QAAQ;AAAA,UAChB;AAAA,UACA,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,eAAe;AAAA,UACf,WAAW;AAAA,QACb;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe;AAAA,UACf,eAAe;AAAA,UACf,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
@@ -1,56 +1,119 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
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
- };
3
+ async function settleAuditClock() {
4
+ await new Promise((resolve) => setTimeout(resolve, 50));
27
5
  }
28
- test.describe("TC-UNDO-001 scheduler.jobs undo/redo (#2504)", () => {
29
- test.beforeAll(() => {
30
- skipIfUndoTestsDisabled();
6
+ function readUndoToken(res) {
7
+ const header = res.headers()["x-om-operation"] ?? "";
8
+ const enc = header.startsWith("omop:") ? header.slice(5) : "";
9
+ expect(enc, "x-om-operation header should carry an omop: payload").not.toBe("");
10
+ const payload = JSON.parse(decodeURIComponent(enc));
11
+ expect(typeof payload.undoToken, "undoToken present in operation payload").toBe("string");
12
+ return payload.undoToken;
13
+ }
14
+ async function getById(request, token, id) {
15
+ const res = await apiRequest(request, "GET", `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token });
16
+ expect(res.status()).toBe(200);
17
+ const body = await res.json();
18
+ return (body.items ?? [])[0];
19
+ }
20
+ async function createJob(request, token, name) {
21
+ const res = await apiRequest(request, "POST", "/api/scheduler/jobs", {
22
+ token,
23
+ data: {
24
+ name,
25
+ description: "TC-UNDO-001 probe",
26
+ scopeType: "tenant",
27
+ scheduleType: "cron",
28
+ scheduleValue: "0 0 * * *",
29
+ timezone: "UTC",
30
+ targetType: "command",
31
+ targetCommand: "scheduler.test.echo",
32
+ isEnabled: true,
33
+ sourceType: "user"
34
+ }
35
+ });
36
+ expect(res.status(), "create returns 201").toBe(201);
37
+ const id = (await res.json()).id;
38
+ expect(id, "create returns an id").toBeTruthy();
39
+ return { id, res };
40
+ }
41
+ async function undo(request, token, undoToken) {
42
+ const res = await apiRequest(request, "POST", "/api/audit_logs/audit-logs/actions/undo", {
43
+ token,
44
+ data: { undoToken }
31
45
  });
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
- });
46
+ const raw = await res.text();
47
+ expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200);
48
+ const body = JSON.parse(raw);
49
+ expect(body.ok, "undo body is { ok: true }").toBe(true);
50
+ }
51
+ async function cleanup(request, token, id) {
52
+ if (!token || !id) return;
53
+ try {
54
+ await apiRequest(request, "DELETE", "/api/scheduler/jobs", { token, data: { id } });
55
+ } catch {
56
+ }
57
+ }
58
+ test.describe("TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)", () => {
59
+ test("UPDATE -> undo restores the prior name", async ({ request }) => {
60
+ let token = null;
61
+ let id = null;
62
+ try {
63
+ token = await getAuthToken(request, "admin");
64
+ const originalName = `TC-UNDO-001 Update ${Date.now()}`;
65
+ ({ id } = await createJob(request, token, originalName));
66
+ await settleAuditClock();
67
+ const updateRes = await apiRequest(request, "PUT", "/api/scheduler/jobs", {
68
+ token,
69
+ data: { id, name: `${originalName} RENAMED` }
70
+ });
71
+ expect(updateRes.status(), "update returns 200").toBe(200);
72
+ expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`);
73
+ await undo(request, token, readUndoToken(updateRes));
74
+ const restored = await getById(request, token, id);
75
+ expect(restored, "job still exists after update-undo").toBeTruthy();
76
+ expect(restored.name, "name restored to original by undo").toBe(originalName);
77
+ } finally {
78
+ await cleanup(request, token, id);
79
+ }
41
80
  });
42
- test("\xA74 trigger exposes no undo token", async ({ request }) => {
43
- const token = await getAuthToken(request, "admin");
44
- let jobId = null;
81
+ test("DELETE -> undo re-lists the soft-deleted job", async ({ request }) => {
82
+ let token = null;
83
+ let id = null;
45
84
  try {
46
- jobId = await createScheduleJob(request, token);
47
- const triggerRes = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
85
+ token = await getAuthToken(request, "admin");
86
+ const name = `TC-UNDO-001 Delete ${Date.now()}`;
87
+ ({ id } = await createJob(request, token, name));
88
+ await settleAuditClock();
89
+ const deleteRes = await apiRequest(request, "DELETE", "/api/scheduler/jobs", {
48
90
  token,
49
- data: { id: jobId }
91
+ data: { id }
50
92
  });
51
- expect(extractOperation(triggerRes), "trigger response carries no undo token (\xA74)").toBeNull();
93
+ expect(deleteRes.status(), "delete returns 200").toBe(200);
94
+ expect(await getById(request, token, id), "job is gone after delete").toBeFalsy();
95
+ await undo(request, token, readUndoToken(deleteRes));
96
+ const restored = await getById(request, token, id);
97
+ expect(restored, "job re-materialized by delete-undo").toBeTruthy();
98
+ expect(restored.name).toBe(name);
99
+ } finally {
100
+ await cleanup(request, token, id);
101
+ }
102
+ });
103
+ test("CREATE -> undo removes the created job", async ({ request }) => {
104
+ let token = null;
105
+ let id = null;
106
+ try {
107
+ token = await getAuthToken(request, "admin");
108
+ const name = `TC-UNDO-001 Create ${Date.now()}`;
109
+ const created = await createJob(request, token, name);
110
+ id = created.id;
111
+ expect(await getById(request, token, id), "job exists after create").toBeTruthy();
112
+ await undo(request, token, readUndoToken(created.res));
113
+ expect(await getById(request, token, id), "job removed by create-undo").toBeFalsy();
114
+ id = null;
52
115
  } finally {
53
- await deleteScheduleJob(request, token, jobId);
116
+ await cleanup(request, token, id);
54
117
  }
55
118
  });
56
119
  });
@@ -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 } 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;",
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\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: 'command',\n targetCommand: 'scheduler.test.echo',\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('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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",
6
6
  "names": []
7
7
  }
@@ -5,13 +5,20 @@ const SCHEDULER_JOBS_PATH = "/api/scheduler/jobs";
5
5
  const SCHEDULER_TARGETS_PATH = "/api/scheduler/targets";
6
6
  const SCHEDULER_TRIGGER_PATH = "/api/scheduler/trigger";
7
7
  const SCHEDULER_EXECUTION_QUEUE = "scheduler-execution";
8
+ const SCHEDULER_TEST_QUEUE = "scheduler-test";
9
+ const SCHEDULER_FORBIDDEN_INTERNAL_QUEUES = [
10
+ SCHEDULER_EXECUTION_QUEUE,
11
+ "stripe-webhook",
12
+ "payment-gateways-webhook"
13
+ ];
14
+ const SCHEDULER_TEST_COMMAND = "scheduler.test.echo";
8
15
  let sequence = 0;
9
16
  function uniqueScheduleName(prefix) {
10
17
  sequence += 1;
11
18
  return `${prefix} ${Date.now()}-${sequence}`;
12
19
  }
13
20
  async function createScheduleJob(request, token, overrides = {}) {
14
- const targetType = overrides.targetType ?? "queue";
21
+ const targetType = overrides.targetType ?? "command";
15
22
  const data = {
16
23
  name: overrides.name ?? uniqueScheduleName("Integration Scheduler"),
17
24
  description: overrides.description ?? "Created by scheduler integration tests",
@@ -25,9 +32,9 @@ async function createScheduleJob(request, token, overrides = {}) {
25
32
  sourceType: overrides.sourceType ?? "user"
26
33
  };
27
34
  if (targetType === "command") {
28
- data.targetCommand = overrides.targetCommand;
35
+ data.targetCommand = overrides.targetCommand ?? SCHEDULER_TEST_COMMAND;
29
36
  } else {
30
- data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE;
37
+ data.targetQueue = overrides.targetQueue ?? SCHEDULER_TEST_QUEUE;
31
38
  }
32
39
  const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, { token, data });
33
40
  const body = await readJsonSafe(response);
@@ -48,8 +55,11 @@ async function deleteScheduleJob(request, token, id) {
48
55
  }
49
56
  export {
50
57
  SCHEDULER_EXECUTION_QUEUE,
58
+ SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
51
59
  SCHEDULER_JOBS_PATH,
52
60
  SCHEDULER_TARGETS_PATH,
61
+ SCHEDULER_TEST_COMMAND,
62
+ SCHEDULER_TEST_QUEUE,
53
63
  SCHEDULER_TRIGGER_PATH,
54
64
  createScheduleJob,
55
65
  deleteScheduleJob,