@open-mercato/scheduler 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0

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 (80) 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 +4 -9
  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-CRUDFORM-001.spec.js +3 -3
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  10. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +44 -107
  11. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  12. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
  13. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  14. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
  15. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +12 -2
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/api/trigger/route.js +13 -7
  19. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  20. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
  21. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  22. package/dist/modules/scheduler/cli.js +1 -32
  23. package/dist/modules/scheduler/cli.js.map +2 -2
  24. package/dist/modules/scheduler/commands/jobs.js +2 -84
  25. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  26. package/dist/modules/scheduler/data/validators.js +3 -27
  27. package/dist/modules/scheduler/data/validators.js.map +2 -2
  28. package/dist/modules/scheduler/i18n/de.json +0 -2
  29. package/dist/modules/scheduler/i18n/en.json +0 -2
  30. package/dist/modules/scheduler/i18n/es.json +0 -2
  31. package/dist/modules/scheduler/i18n/ko.json +0 -2
  32. package/dist/modules/scheduler/i18n/pl.json +0 -2
  33. package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
  34. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  35. package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
  36. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  37. package/package.json +6 -7
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  42. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +66 -154
  43. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
  44. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
  46. package/src/modules/scheduler/api/targets/route.ts +17 -6
  47. package/src/modules/scheduler/api/trigger/route.ts +19 -9
  48. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
  49. package/src/modules/scheduler/cli.ts +1 -44
  50. package/src/modules/scheduler/commands/jobs.ts +3 -137
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
  52. package/src/modules/scheduler/data/validators.ts +2 -37
  53. package/src/modules/scheduler/i18n/de.json +0 -2
  54. package/src/modules/scheduler/i18n/en.json +0 -2
  55. package/src/modules/scheduler/i18n/es.json +0 -2
  56. package/src/modules/scheduler/i18n/ko.json +0 -2
  57. package/src/modules/scheduler/i18n/pl.json +0 -2
  58. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
  59. package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
  60. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
  61. package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
  62. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
  63. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
  64. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
  65. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
  66. package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
  67. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
  68. package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
  69. package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
  70. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
  71. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
  72. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
  73. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
  74. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
  75. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
  76. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
  77. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
  78. package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
  79. package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 60 entry points
1
+ [build:scheduler] found 55 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: "command",
15
- targetCommand: "scheduler.test.echo",
14
+ targetType: "queue",
15
+ targetQueue: "scheduler-execution",
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: '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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",
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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",
6
6
  "names": []
7
7
  }
@@ -1,10 +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 {
5
- SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
- SCHEDULER_TARGETS_PATH
7
- } from "./helpers/scheduler.js";
4
+ import { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from "./helpers/scheduler.js";
8
5
  function expectSortedByValue(options, label) {
9
6
  const values = options.map((option) => option.value);
10
7
  expect(values, `${label} should be sorted alphabetically by value`).toEqual(
@@ -18,7 +15,7 @@ function expectOptionShape(options, label) {
18
15
  }
19
16
  }
20
17
  test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and commands", () => {
21
- test("returns only scheduler-safe queues plus a non-empty sorted command list", async ({ request }) => {
18
+ test("returns sorted queues (incl. scheduler-execution) and a non-empty command list", async ({ request }) => {
22
19
  const token = await getAuthToken(request, "admin");
23
20
  const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
24
21
  expect(response.status()).toBe(200);
@@ -26,12 +23,10 @@ test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and command
26
23
  const queues = body?.queues ?? [];
27
24
  const commands = body?.commands ?? [];
28
25
  expect(Array.isArray(body?.queues)).toBe(true);
26
+ expect(queues.length).toBeGreaterThan(0);
29
27
  expectOptionShape(queues, "queues");
28
+ expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE);
30
29
  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
- }
35
30
  expect(Array.isArray(body?.commands)).toBe(true);
36
31
  expect(commands.length).toBeGreaterThan(0);
37
32
  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 {\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;",
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;",
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_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from "./helpers/scheduler.js";
4
+ import { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, 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: "command",
27
- targetCommand: SCHEDULER_TEST_COMMAND,
26
+ targetType: "queue",
27
+ targetQueue: SCHEDULER_EXECUTION_QUEUE,
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_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;",
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;",
6
6
  "names": []
7
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-test",
53
+ targetQueue: "scheduler-execution",
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-test",
68
+ targetQueue: "scheduler-execution",
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-test",
96
+ targetQueue: "scheduler-execution",
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-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"],
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"],
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,119 +1,56 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
- async function settleAuditClock() {
4
- 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
+ };
5
27
  }
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 }
28
+ test.describe("TC-UNDO-001 scheduler.jobs undo/redo (#2504)", () => {
29
+ test.beforeAll(() => {
30
+ skipIfUndoTestsDisabled();
45
31
  });
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
- }
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
+ });
80
41
  });
81
- test("DELETE -> undo re-lists the soft-deleted job", async ({ request }) => {
82
- let token = null;
83
- let id = null;
42
+ test("\xA74 trigger exposes no undo token", async ({ request }) => {
43
+ const token = await getAuthToken(request, "admin");
44
+ let jobId = null;
84
45
  try {
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", {
46
+ jobId = await createScheduleJob(request, token);
47
+ const triggerRes = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
90
48
  token,
91
- data: { id }
49
+ data: { id: jobId }
92
50
  });
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;
51
+ expect(extractOperation(triggerRes), "trigger response carries no undo token (\xA74)").toBeNull();
115
52
  } finally {
116
- await cleanup(request, token, id);
53
+ await deleteScheduleJob(request, token, jobId);
117
54
  }
118
55
  });
119
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'\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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",
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
  }
@@ -5,20 +5,13 @@ 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";
15
8
  let sequence = 0;
16
9
  function uniqueScheduleName(prefix) {
17
10
  sequence += 1;
18
11
  return `${prefix} ${Date.now()}-${sequence}`;
19
12
  }
20
13
  async function createScheduleJob(request, token, overrides = {}) {
21
- const targetType = overrides.targetType ?? "command";
14
+ const targetType = overrides.targetType ?? "queue";
22
15
  const data = {
23
16
  name: overrides.name ?? uniqueScheduleName("Integration Scheduler"),
24
17
  description: overrides.description ?? "Created by scheduler integration tests",
@@ -32,9 +25,9 @@ async function createScheduleJob(request, token, overrides = {}) {
32
25
  sourceType: overrides.sourceType ?? "user"
33
26
  };
34
27
  if (targetType === "command") {
35
- data.targetCommand = overrides.targetCommand ?? SCHEDULER_TEST_COMMAND;
28
+ data.targetCommand = overrides.targetCommand;
36
29
  } else {
37
- data.targetQueue = overrides.targetQueue ?? SCHEDULER_TEST_QUEUE;
30
+ data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE;
38
31
  }
39
32
  const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, { token, data });
40
33
  const body = await readJsonSafe(response);
@@ -55,11 +48,8 @@ async function deleteScheduleJob(request, token, id) {
55
48
  }
56
49
  export {
57
50
  SCHEDULER_EXECUTION_QUEUE,
58
- SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
59
51
  SCHEDULER_JOBS_PATH,
60
52
  SCHEDULER_TARGETS_PATH,
61
- SCHEDULER_TEST_COMMAND,
62
- SCHEDULER_TEST_QUEUE,
63
53
  SCHEDULER_TRIGGER_PATH,
64
54
  createScheduleJob,
65
55
  deleteScheduleJob,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/scheduler/__integration__/helpers/scheduler.ts"],
4
- "sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\nexport const SCHEDULER_JOBS_PATH = '/api/scheduler/jobs'\nexport const SCHEDULER_TARGETS_PATH = '/api/scheduler/targets'\nexport const SCHEDULER_TRIGGER_PATH = '/api/scheduler/trigger'\n\n/** Queue registered by the scheduler module's execute-schedule worker (internal, not schedulable). */\nexport const SCHEDULER_EXECUTION_QUEUE = 'scheduler-execution'\n\n/** Deliberately side-effect-free queue whose worker opted into scheduling (#5213). */\nexport const SCHEDULER_TEST_QUEUE = 'scheduler-test'\n\n/**\n * Internal queues that must never be offered as scheduler targets (#5213).\n * Workers must opt in via `schedulerSafe: true` to become schedulable.\n */\nexport const SCHEDULER_FORBIDDEN_INTERNAL_QUEUES = [\n SCHEDULER_EXECUTION_QUEUE,\n 'stripe-webhook',\n 'payment-gateways-webhook',\n] as const\n\n/** Always-registered command used by fixtures now that queue targets are restricted. */\nexport const SCHEDULER_TEST_COMMAND = 'scheduler.test.echo'\n\nexport type SchedulerJob = {\n id: string\n name: string\n description: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId: string | null\n tenantId: string | null\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone: string\n targetType: 'queue' | 'command'\n targetQueue: string | null\n targetCommand: string | null\n isEnabled: boolean\n createdAt: string | null\n updatedAt: string | null\n}\n\nexport type CreateScheduleOverrides = {\n name?: string\n description?: string | null\n scopeType?: 'system' | 'organization' | 'tenant'\n scheduleType?: 'cron' | 'interval'\n scheduleValue?: string\n timezone?: string\n targetType?: 'queue' | 'command'\n targetQueue?: string\n targetCommand?: string\n targetPayload?: Record<string, unknown>\n isEnabled?: boolean\n sourceType?: 'user' | 'module'\n}\n\nlet sequence = 0\n\n/** Stable-ish unique label so concurrent fixtures never collide on name. */\nexport function uniqueScheduleName(prefix: string): string {\n sequence += 1\n return `${prefix} ${Date.now()}-${sequence}`\n}\n\n/**\n * Create an organization-scoped schedule via the API and return its id.\n * Org/tenant are derived server-side from the caller's auth context, so the caller\n * only needs an admin (or otherwise scheduler.jobs.manage) token.\n *\n * Defaults to a command target: user-authored queue targets are restricted to\n * scheduler-safe queues (#5213), so fixtures must not assume a queue is allowed.\n */\nexport async function createScheduleJob(\n request: APIRequestContext,\n token: string,\n overrides: CreateScheduleOverrides = {},\n): Promise<string> {\n const targetType = overrides.targetType ?? 'command'\n const data: Record<string, unknown> = {\n name: overrides.name ?? uniqueScheduleName('Integration Scheduler'),\n description: overrides.description ?? 'Created by scheduler integration tests',\n scopeType: overrides.scopeType ?? 'organization',\n scheduleType: overrides.scheduleType ?? 'interval',\n scheduleValue: overrides.scheduleValue ?? '15m',\n timezone: overrides.timezone ?? 'UTC',\n targetType,\n targetPayload: overrides.targetPayload ?? { source: 'integration-test' },\n isEnabled: overrides.isEnabled ?? true,\n sourceType: overrides.sourceType ?? 'user',\n }\n if (targetType === 'command') {\n data.targetCommand = overrides.targetCommand ?? SCHEDULER_TEST_COMMAND\n } else {\n data.targetQueue = overrides.targetQueue ?? SCHEDULER_TEST_QUEUE\n }\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, { token, data })\n const body = await readJsonSafe<{ id?: string }>(response)\n expect(response.status(), 'POST /api/scheduler/jobs should return 201').toBe(201)\n const id = body?.id\n expect(typeof id === 'string' && /^[0-9a-f-]{36}$/i.test(id), 'create response should include a UUID id').toBe(true)\n return String(id)\n}\n\n/** Fetch a single schedule by id from the list endpoint; null when absent (e.g. soft-deleted). */\nexport async function getScheduleJobById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<SchedulerJob | null> {\n const response = await apiRequest(request, 'GET', `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}`, { token })\n expect(response.status(), 'GET /api/scheduler/jobs?id should return 200').toBe(200)\n const body = await readJsonSafe<{ items?: SchedulerJob[] }>(response)\n return (body?.items ?? []).find((item) => item.id === id) ?? null\n}\n\n/** Best-effort cleanup; swallows errors so teardown never masks the real assertion. */\nexport async function deleteScheduleJob(\n request: APIRequestContext,\n token: string,\n id: string | null,\n): Promise<void> {\n if (!id) return\n await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, { token, data: { id } }).catch(() => null)\n}\n"],
5
- "mappings": "AAAA,SAAS,cAAsC;AAC/C,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAEtB,MAAM,sBAAsB;AAC5B,MAAM,yBAAyB;AAC/B,MAAM,yBAAyB;AAG/B,MAAM,4BAA4B;AAGlC,MAAM,uBAAuB;AAM7B,MAAM,sCAAsC;AAAA,EACjD;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,yBAAyB;AAmCtC,IAAI,WAAW;AAGR,SAAS,mBAAmB,QAAwB;AACzD,cAAY;AACZ,SAAO,GAAG,MAAM,IAAI,KAAK,IAAI,CAAC,IAAI,QAAQ;AAC5C;AAUA,eAAsB,kBACpB,SACA,OACA,YAAqC,CAAC,GACrB;AACjB,QAAM,aAAa,UAAU,cAAc;AAC3C,QAAM,OAAgC;AAAA,IACpC,MAAM,UAAU,QAAQ,mBAAmB,uBAAuB;AAAA,IAClE,aAAa,UAAU,eAAe;AAAA,IACtC,WAAW,UAAU,aAAa;AAAA,IAClC,cAAc,UAAU,gBAAgB;AAAA,IACxC,eAAe,UAAU,iBAAiB;AAAA,IAC1C,UAAU,UAAU,YAAY;AAAA,IAChC;AAAA,IACA,eAAe,UAAU,iBAAiB,EAAE,QAAQ,mBAAmB;AAAA,IACvE,WAAW,UAAU,aAAa;AAAA,IAClC,YAAY,UAAU,cAAc;AAAA,EACtC;AACA,MAAI,eAAe,WAAW;AAC5B,SAAK,gBAAgB,UAAU,iBAAiB;AAAA,EAClD,OAAO;AACL,SAAK,cAAc,UAAU,eAAe;AAAA,EAC9C;AAEA,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB,EAAE,OAAO,KAAK,CAAC;AACvF,QAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,SAAO,SAAS,OAAO,GAAG,4CAA4C,EAAE,KAAK,GAAG;AAChF,QAAM,KAAK,MAAM;AACjB,SAAO,OAAO,OAAO,YAAY,mBAAmB,KAAK,EAAE,GAAG,0CAA0C,EAAE,KAAK,IAAI;AACnH,SAAO,OAAO,EAAE;AAClB;AAGA,eAAsB,mBACpB,SACA,OACA,IAC8B;AAC9B,QAAM,WAAW,MAAM,WAAW,SAAS,OAAO,GAAG,mBAAmB,OAAO,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC;AAClH,SAAO,SAAS,OAAO,GAAG,8CAA8C,EAAE,KAAK,GAAG;AAClF,QAAM,OAAO,MAAM,aAAyC,QAAQ;AACpE,UAAQ,MAAM,SAAS,CAAC,GAAG,KAAK,CAAC,SAAS,KAAK,OAAO,EAAE,KAAK;AAC/D;AAGA,eAAsB,kBACpB,SACA,OACA,IACe;AACf,MAAI,CAAC,GAAI;AACT,QAAM,WAAW,SAAS,UAAU,qBAAqB,EAAE,OAAO,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,MAAM,MAAM,IAAI;AACpG;",
4
+ "sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\nexport const SCHEDULER_JOBS_PATH = '/api/scheduler/jobs'\nexport const SCHEDULER_TARGETS_PATH = '/api/scheduler/targets'\nexport const SCHEDULER_TRIGGER_PATH = '/api/scheduler/trigger'\n\n/** Queue registered by the scheduler module's execute-schedule worker. */\nexport const SCHEDULER_EXECUTION_QUEUE = 'scheduler-execution'\n\nexport type SchedulerJob = {\n id: string\n name: string\n description: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId: string | null\n tenantId: string | null\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone: string\n targetType: 'queue' | 'command'\n targetQueue: string | null\n targetCommand: string | null\n isEnabled: boolean\n createdAt: string | null\n updatedAt: string | null\n}\n\nexport type CreateScheduleOverrides = {\n name?: string\n description?: string | null\n scopeType?: 'system' | 'organization' | 'tenant'\n scheduleType?: 'cron' | 'interval'\n scheduleValue?: string\n timezone?: string\n targetType?: 'queue' | 'command'\n targetQueue?: string\n targetCommand?: string\n targetPayload?: Record<string, unknown>\n isEnabled?: boolean\n sourceType?: 'user' | 'module'\n}\n\nlet sequence = 0\n\n/** Stable-ish unique label so concurrent fixtures never collide on name. */\nexport function uniqueScheduleName(prefix: string): string {\n sequence += 1\n return `${prefix} ${Date.now()}-${sequence}`\n}\n\n/**\n * Create an organization-scoped queue-target schedule via the API and return its id.\n * Org/tenant are derived server-side from the caller's auth context, so the caller\n * only needs an admin (or otherwise scheduler.jobs.manage) token.\n */\nexport async function createScheduleJob(\n request: APIRequestContext,\n token: string,\n overrides: CreateScheduleOverrides = {},\n): Promise<string> {\n const targetType = overrides.targetType ?? 'queue'\n const data: Record<string, unknown> = {\n name: overrides.name ?? uniqueScheduleName('Integration Scheduler'),\n description: overrides.description ?? 'Created by scheduler integration tests',\n scopeType: overrides.scopeType ?? 'organization',\n scheduleType: overrides.scheduleType ?? 'interval',\n scheduleValue: overrides.scheduleValue ?? '15m',\n timezone: overrides.timezone ?? 'UTC',\n targetType,\n targetPayload: overrides.targetPayload ?? { source: 'integration-test' },\n isEnabled: overrides.isEnabled ?? true,\n sourceType: overrides.sourceType ?? 'user',\n }\n if (targetType === 'command') {\n data.targetCommand = overrides.targetCommand\n } else {\n data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE\n }\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, { token, data })\n const body = await readJsonSafe<{ id?: string }>(response)\n expect(response.status(), 'POST /api/scheduler/jobs should return 201').toBe(201)\n const id = body?.id\n expect(typeof id === 'string' && /^[0-9a-f-]{36}$/i.test(id), 'create response should include a UUID id').toBe(true)\n return String(id)\n}\n\n/** Fetch a single schedule by id from the list endpoint; null when absent (e.g. soft-deleted). */\nexport async function getScheduleJobById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<SchedulerJob | null> {\n const response = await apiRequest(request, 'GET', `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}`, { token })\n expect(response.status(), 'GET /api/scheduler/jobs?id should return 200').toBe(200)\n const body = await readJsonSafe<{ items?: SchedulerJob[] }>(response)\n return (body?.items ?? []).find((item) => item.id === id) ?? null\n}\n\n/** Best-effort cleanup; swallows errors so teardown never masks the real assertion. */\nexport async function deleteScheduleJob(\n request: APIRequestContext,\n token: string,\n id: string | null,\n): Promise<void> {\n if (!id) return\n await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, { token, data: { id } }).catch(() => null)\n}\n"],
5
+ "mappings": "AAAA,SAAS,cAAsC;AAC/C,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAEtB,MAAM,sBAAsB;AAC5B,MAAM,yBAAyB;AAC/B,MAAM,yBAAyB;AAG/B,MAAM,4BAA4B;AAmCzC,IAAI,WAAW;AAGR,SAAS,mBAAmB,QAAwB;AACzD,cAAY;AACZ,SAAO,GAAG,MAAM,IAAI,KAAK,IAAI,CAAC,IAAI,QAAQ;AAC5C;AAOA,eAAsB,kBACpB,SACA,OACA,YAAqC,CAAC,GACrB;AACjB,QAAM,aAAa,UAAU,cAAc;AAC3C,QAAM,OAAgC;AAAA,IACpC,MAAM,UAAU,QAAQ,mBAAmB,uBAAuB;AAAA,IAClE,aAAa,UAAU,eAAe;AAAA,IACtC,WAAW,UAAU,aAAa;AAAA,IAClC,cAAc,UAAU,gBAAgB;AAAA,IACxC,eAAe,UAAU,iBAAiB;AAAA,IAC1C,UAAU,UAAU,YAAY;AAAA,IAChC;AAAA,IACA,eAAe,UAAU,iBAAiB,EAAE,QAAQ,mBAAmB;AAAA,IACvE,WAAW,UAAU,aAAa;AAAA,IAClC,YAAY,UAAU,cAAc;AAAA,EACtC;AACA,MAAI,eAAe,WAAW;AAC5B,SAAK,gBAAgB,UAAU;AAAA,EACjC,OAAO;AACL,SAAK,cAAc,UAAU,eAAe;AAAA,EAC9C;AAEA,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB,EAAE,OAAO,KAAK,CAAC;AACvF,QAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,SAAO,SAAS,OAAO,GAAG,4CAA4C,EAAE,KAAK,GAAG;AAChF,QAAM,KAAK,MAAM;AACjB,SAAO,OAAO,OAAO,YAAY,mBAAmB,KAAK,EAAE,GAAG,0CAA0C,EAAE,KAAK,IAAI;AACnH,SAAO,OAAO,EAAE;AAClB;AAGA,eAAsB,mBACpB,SACA,OACA,IAC8B;AAC9B,QAAM,WAAW,MAAM,WAAW,SAAS,OAAO,GAAG,mBAAmB,OAAO,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC;AAClH,SAAO,SAAS,OAAO,GAAG,8CAA8C,EAAE,KAAK,GAAG;AAClF,QAAM,OAAO,MAAM,aAAyC,QAAQ;AACpE,UAAQ,MAAM,SAAS,CAAC,GAAG,KAAK,CAAC,SAAS,KAAK,OAAO,EAAE,KAAK;AAC/D;AAGA,eAAsB,kBACpB,SACA,OACA,IACe;AACf,MAAI,CAAC,GAAI;AACT,QAAM,WAAW,SAAS,UAAU,qBAAqB,EAAE,OAAO,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,MAAM,MAAM,IAAI;AACpG;",
6
6
  "names": []
7
7
  }
@@ -3,7 +3,6 @@ import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
5
  import { ScheduledJob } from "../../../../data/entities.js";
6
- import { resolveScheduleAccess } from "../../../../lib/scheduleAccess.js";
7
6
  import { getRedisUrlOrThrow, parseRedisUrl } from "@open-mercato/shared/lib/redis/connection";
8
7
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
9
8
  import { createLogger } from "@open-mercato/shared/lib/logger";
@@ -22,15 +21,22 @@ async function GET(req, { params }) {
22
21
  const em = container.resolve("em");
23
22
  const scheduleId = params.id;
24
23
  try {
25
- const schedule = await em.findOne(ScheduledJob, { id: scheduleId, deletedAt: null });
26
- if (!schedule) {
27
- return NextResponse.json({ error: translate("scheduler.error.not_found", "Schedule not found") }, { status: 404 });
24
+ const findFilter = {
25
+ id: scheduleId,
26
+ deletedAt: null
27
+ };
28
+ if (auth.tenantId) {
29
+ findFilter.tenantId = auth.tenantId;
28
30
  }
29
- const access = resolveScheduleAccess(schedule, auth);
30
- if (access === "not_found") {
31
+ if (auth.orgId) {
32
+ findFilter.organizationId = auth.orgId;
33
+ }
34
+ const schedule = await em.findOne(ScheduledJob, findFilter);
35
+ if (!schedule) {
31
36
  return NextResponse.json({ error: translate("scheduler.error.not_found", "Schedule not found") }, { status: 404 });
32
37
  }
33
- if (access === "forbidden") {
38
+ const isSuperAdmin = auth.isSuperAdmin === true;
39
+ if (!schedule.tenantId && !schedule.organizationId && !isSuperAdmin) {
34
40
  return NextResponse.json({ error: translate("scheduler.error.access_denied", "Access denied") }, { status: 403 });
35
41
  }
36
42
  const queueStrategy = process.env.QUEUE_STRATEGY || "local";