@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +9 -4
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +72 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
- package/dist/modules/scheduler/api/targets/route.js +2 -12
- package/dist/modules/scheduler/api/targets/route.js.map +2 -2
- package/dist/modules/scheduler/cli.js +32 -1
- package/dist/modules/scheduler/cli.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +84 -2
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/data/validators.js +27 -3
- package/dist/modules/scheduler/data/validators.js.map +2 -2
- package/dist/modules/scheduler/i18n/de.json +2 -0
- package/dist/modules/scheduler/i18n/en.json +2 -0
- package/dist/modules/scheduler/i18n/es.json +2 -0
- package/dist/modules/scheduler/i18n/ko.json +2 -0
- package/dist/modules/scheduler/i18n/pl.json +2 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
- package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
- package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
- package/src/modules/scheduler/api/targets/route.ts +6 -17
- package/src/modules/scheduler/cli.ts +44 -1
- package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
- package/src/modules/scheduler/commands/jobs.ts +137 -3
- package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
- package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
- package/src/modules/scheduler/data/validators.ts +37 -2
- package/src/modules/scheduler/i18n/de.json +2 -0
- package/src/modules/scheduler/i18n/en.json +2 -0
- package/src/modules/scheduler/i18n/es.json +2 -0
- package/src/modules/scheduler/i18n/ko.json +2 -0
- package/src/modules/scheduler/i18n/pl.json +2 -0
- package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
- package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
- package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
- package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:scheduler] found
|
|
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: "
|
|
15
|
-
|
|
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: '
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AAEzC,MAAM,wBAAwB,QAAQ,IAAI,kBAAkB,aAAa;AAMzE,eAAe,eAAe,SAAuD,OAAe;AAClG,QAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,uBAAuB;AAAA,IACxE;AAAA,IACA,MAAM;AAAA,MACJ,MAAM,yBAAyB,KAAK,IAAI,CAAC;AAAA,MACzC,aAAa;AAAA,MACb,WAAW;AAAA,MACX,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,
|
|
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 {
|
|
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
|
|
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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,
|
|
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 {
|
|
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: "
|
|
27
|
-
|
|
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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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("
|
|
43
|
-
|
|
44
|
-
let
|
|
81
|
+
test("DELETE -> undo re-lists the soft-deleted job", async ({ request }) => {
|
|
82
|
+
let token = null;
|
|
83
|
+
let id = null;
|
|
45
84
|
try {
|
|
46
|
-
|
|
47
|
-
const
|
|
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
|
|
91
|
+
data: { id }
|
|
50
92
|
});
|
|
51
|
-
expect(
|
|
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
|
|
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'\
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,
|
|
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 ?? "
|
|
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 ??
|
|
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,
|