@open-mercato/scheduler 0.6.5-develop.4588.1.ecaa16cfc0 → 0.6.5-develop.4620.1.c20bc7e4bb
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-002.spec.js +49 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-002.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-003.spec.js +38 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-003.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +36 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-005.spec.js +41 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-005.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-006.spec.js +61 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-006.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +36 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +59 -0
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-002.spec.ts +63 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-003.spec.ts +48 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +52 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-005.spec.ts +51 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-006.spec.ts +84 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +50 -0
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +110 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:scheduler] found
|
|
1
|
+
[build:scheduler] found 50 entry points
|
|
2
2
|
[build:scheduler] built successfully
|
|
@@ -0,0 +1,49 @@
|
|
|
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_JOBS_PATH,
|
|
6
|
+
createScheduleJob,
|
|
7
|
+
deleteScheduleJob,
|
|
8
|
+
getScheduleJobById,
|
|
9
|
+
uniqueScheduleName
|
|
10
|
+
} from "./helpers/scheduler.js";
|
|
11
|
+
test.describe("TC-SCHED-002: PUT /api/scheduler/jobs updates schedule fields", () => {
|
|
12
|
+
test("updates name and scheduleValue while preserving identity and scope", async ({ request }) => {
|
|
13
|
+
const token = await getAuthToken(request, "admin");
|
|
14
|
+
let scheduleId = null;
|
|
15
|
+
try {
|
|
16
|
+
scheduleId = await createScheduleJob(request, token, {
|
|
17
|
+
name: uniqueScheduleName("Sched Update"),
|
|
18
|
+
scheduleType: "interval",
|
|
19
|
+
scheduleValue: "15m"
|
|
20
|
+
});
|
|
21
|
+
const original = await getScheduleJobById(request, token, scheduleId);
|
|
22
|
+
expect(original).not.toBeNull();
|
|
23
|
+
expect(original.scheduleValue).toBe("15m");
|
|
24
|
+
const updateResponse = await apiRequest(request, "PUT", SCHEDULER_JOBS_PATH, {
|
|
25
|
+
token,
|
|
26
|
+
data: { id: scheduleId, name: "Updated-Test-1", scheduleType: "interval", scheduleValue: "30m" }
|
|
27
|
+
});
|
|
28
|
+
expect(updateResponse.status()).toBe(200);
|
|
29
|
+
const updateBody = await readJsonSafe(updateResponse);
|
|
30
|
+
expect(updateBody?.ok).toBe(true);
|
|
31
|
+
const updated = await getScheduleJobById(request, token, scheduleId);
|
|
32
|
+
expect(updated).not.toBeNull();
|
|
33
|
+
expect(updated.name).toBe("Updated-Test-1");
|
|
34
|
+
expect(updated.scheduleValue).toBe("30m");
|
|
35
|
+
expect(updated.id).toBe(scheduleId);
|
|
36
|
+
expect(updated.tenantId).toBe(original.tenantId);
|
|
37
|
+
expect(updated.organizationId).toBe(original.organizationId);
|
|
38
|
+
expect(updated.targetQueue).toBe(original.targetQueue);
|
|
39
|
+
if (original.createdAt && updated.updatedAt) {
|
|
40
|
+
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
|
|
41
|
+
new Date(original.createdAt).getTime()
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
await deleteScheduleJob(request, token, scheduleId);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=TC-SCHED-002.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-002.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_JOBS_PATH,\n createScheduleJob,\n deleteScheduleJob,\n getScheduleJobById,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\n/**\n * TC-SCHED-002: PUT /api/scheduler/jobs updates schedule fields correctly\n *\n * Note: scheduleUpdateSchema requires `scheduleType` whenever `scheduleValue`\n * changes, so the update payload sends both (the issue's draft omitted the\n * type \u2014 the route rejects that with 400). This asserts the real contract.\n */\ntest.describe('TC-SCHED-002: PUT /api/scheduler/jobs updates schedule fields', () => {\n test('updates name and scheduleValue while preserving identity and scope', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createScheduleJob(request, token, {\n name: uniqueScheduleName('Sched Update'),\n scheduleType: 'interval',\n scheduleValue: '15m',\n })\n\n const original = await getScheduleJobById(request, token, scheduleId)\n expect(original).not.toBeNull()\n expect(original!.scheduleValue).toBe('15m')\n\n const updateResponse = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {\n token,\n data: { id: scheduleId, name: 'Updated-Test-1', scheduleType: 'interval', scheduleValue: '30m' },\n })\n expect(updateResponse.status()).toBe(200)\n const updateBody = await readJsonSafe<{ ok?: boolean }>(updateResponse)\n expect(updateBody?.ok).toBe(true)\n\n const updated = await getScheduleJobById(request, token, scheduleId)\n expect(updated).not.toBeNull()\n // Mutated fields persisted\n expect(updated!.name).toBe('Updated-Test-1')\n expect(updated!.scheduleValue).toBe('30m')\n // Identity and scope unchanged\n expect(updated!.id).toBe(scheduleId)\n expect(updated!.tenantId).toBe(original!.tenantId)\n expect(updated!.organizationId).toBe(original!.organizationId)\n expect(updated!.targetQueue).toBe(original!.targetQueue)\n // updatedAt advances past the original creation timestamp\n if (original!.createdAt && updated!.updatedAt) {\n expect(new Date(updated!.updatedAt).getTime()).toBeGreaterThanOrEqual(\n new Date(original!.createdAt).getTime(),\n )\n }\n } finally {\n await deleteScheduleJob(request, token, scheduleId)\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,OACK;AASP,KAAK,SAAS,iEAAiE,MAAM;AACnF,OAAK,sEAAsE,OAAO,EAAE,QAAQ,MAAM;AAChG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAEhC,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,OAAO;AAAA,QACnD,MAAM,mBAAmB,cAAc;AAAA,QACvC,cAAc;AAAA,QACd,eAAe;AAAA,MACjB,CAAC;AAED,YAAM,WAAW,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACpE,aAAO,QAAQ,EAAE,IAAI,SAAS;AAC9B,aAAO,SAAU,aAAa,EAAE,KAAK,KAAK;AAE1C,YAAM,iBAAiB,MAAM,WAAW,SAAS,OAAO,qBAAqB;AAAA,QAC3E;AAAA,QACA,MAAM,EAAE,IAAI,YAAY,MAAM,kBAAkB,cAAc,YAAY,eAAe,MAAM;AAAA,MACjG,CAAC;AACD,aAAO,eAAe,OAAO,CAAC,EAAE,KAAK,GAAG;AACxC,YAAM,aAAa,MAAM,aAA+B,cAAc;AACtE,aAAO,YAAY,EAAE,EAAE,KAAK,IAAI;AAEhC,YAAM,UAAU,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACnE,aAAO,OAAO,EAAE,IAAI,SAAS;AAE7B,aAAO,QAAS,IAAI,EAAE,KAAK,gBAAgB;AAC3C,aAAO,QAAS,aAAa,EAAE,KAAK,KAAK;AAEzC,aAAO,QAAS,EAAE,EAAE,KAAK,UAAU;AACnC,aAAO,QAAS,QAAQ,EAAE,KAAK,SAAU,QAAQ;AACjD,aAAO,QAAS,cAAc,EAAE,KAAK,SAAU,cAAc;AAC7D,aAAO,QAAS,WAAW,EAAE,KAAK,SAAU,WAAW;AAEvD,UAAI,SAAU,aAAa,QAAS,WAAW;AAC7C,eAAO,IAAI,KAAK,QAAS,SAAS,EAAE,QAAQ,CAAC,EAAE;AAAA,UAC7C,IAAI,KAAK,SAAU,SAAS,EAAE,QAAQ;AAAA,QACxC;AAAA,MACF;AAAA,IACF,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IACpD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob, getScheduleJobById } from "./helpers/scheduler.js";
|
|
5
|
+
test.describe("TC-SCHED-003: DELETE /api/scheduler/jobs soft-deletes a schedule", () => {
|
|
6
|
+
test("removes the schedule from listings and returns 200 (not 404) afterwards", async ({ request }) => {
|
|
7
|
+
const token = await getAuthToken(request, "admin");
|
|
8
|
+
let scheduleId = null;
|
|
9
|
+
let deleted = false;
|
|
10
|
+
try {
|
|
11
|
+
scheduleId = await createScheduleJob(request, token);
|
|
12
|
+
const present = await getScheduleJobById(request, token, scheduleId);
|
|
13
|
+
expect(present).not.toBeNull();
|
|
14
|
+
const deleteResponse = await apiRequest(request, "DELETE", SCHEDULER_JOBS_PATH, {
|
|
15
|
+
token,
|
|
16
|
+
data: { id: scheduleId }
|
|
17
|
+
});
|
|
18
|
+
expect(deleteResponse.status()).toBe(200);
|
|
19
|
+
const deleteBody = await readJsonSafe(deleteResponse);
|
|
20
|
+
expect(deleteBody?.ok).toBe(true);
|
|
21
|
+
deleted = true;
|
|
22
|
+
const afterResponse = await apiRequest(
|
|
23
|
+
request,
|
|
24
|
+
"GET",
|
|
25
|
+
`${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(scheduleId)}`,
|
|
26
|
+
{ token }
|
|
27
|
+
);
|
|
28
|
+
expect(afterResponse.status()).toBe(200);
|
|
29
|
+
const afterBody = await readJsonSafe(afterResponse);
|
|
30
|
+
expect(Array.isArray(afterBody?.items)).toBe(true);
|
|
31
|
+
expect((afterBody?.items ?? []).some((item) => item.id === scheduleId)).toBe(false);
|
|
32
|
+
expect((afterBody?.items ?? []).length).toBe(0);
|
|
33
|
+
} finally {
|
|
34
|
+
if (!deleted) await deleteScheduleJob(request, token, scheduleId);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=TC-SCHED-003.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-003.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, createScheduleJob, deleteScheduleJob, getScheduleJobById } from './helpers/scheduler'\n\n/**\n * TC-SCHED-003: DELETE /api/scheduler/jobs soft-deletes a schedule and\n * prevents re-listing. The list endpoint keeps returning 200 (not 404) with\n * the soft-deleted record filtered out by the `deletedAt` soft-delete field.\n */\ntest.describe('TC-SCHED-003: DELETE /api/scheduler/jobs soft-deletes a schedule', () => {\n test('removes the schedule from listings and returns 200 (not 404) afterwards', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n let deleted = false\n\n try {\n scheduleId = await createScheduleJob(request, token)\n\n const present = await getScheduleJobById(request, token, scheduleId)\n expect(present).not.toBeNull()\n\n const deleteResponse = await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, {\n token,\n data: { id: scheduleId },\n })\n expect(deleteResponse.status()).toBe(200)\n const deleteBody = await readJsonSafe<{ ok?: boolean }>(deleteResponse)\n expect(deleteBody?.ok).toBe(true)\n deleted = true\n\n // Soft delete: list still returns 200 with an (empty) items array, never 404.\n const afterResponse = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(scheduleId)}`,\n { token },\n )\n expect(afterResponse.status()).toBe(200)\n const afterBody = await readJsonSafe<{ items?: Array<{ id: string }> }>(afterResponse)\n expect(Array.isArray(afterBody?.items)).toBe(true)\n expect((afterBody?.items ?? []).some((item) => item.id === scheduleId)).toBe(false)\n expect((afterBody?.items ?? []).length).toBe(0)\n } finally {\n if (!deleted) await deleteScheduleJob(request, token, scheduleId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB,mBAAmB,mBAAmB,0BAA0B;AAO9F,KAAK,SAAS,oEAAoE,MAAM;AACtF,OAAK,2EAA2E,OAAO,EAAE,QAAQ,MAAM;AACrG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI,UAAU;AAEd,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,KAAK;AAEnD,YAAM,UAAU,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACnE,aAAO,OAAO,EAAE,IAAI,SAAS;AAE7B,YAAM,iBAAiB,MAAM,WAAW,SAAS,UAAU,qBAAqB;AAAA,QAC9E;AAAA,QACA,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD,aAAO,eAAe,OAAO,CAAC,EAAE,KAAK,GAAG;AACxC,YAAM,aAAa,MAAM,aAA+B,cAAc;AACtE,aAAO,YAAY,EAAE,EAAE,KAAK,IAAI;AAChC,gBAAU;AAGV,YAAM,gBAAgB,MAAM;AAAA,QAC1B;AAAA,QACA;AAAA,QACA,GAAG,mBAAmB,OAAO,mBAAmB,UAAU,CAAC;AAAA,QAC3D,EAAE,MAAM;AAAA,MACV;AACA,aAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,YAAM,YAAY,MAAM,aAAgD,aAAa;AACrF,aAAO,MAAM,QAAQ,WAAW,KAAK,CAAC,EAAE,KAAK,IAAI;AACjD,cAAQ,WAAW,SAAS,CAAC,GAAG,KAAK,CAAC,SAAS,KAAK,OAAO,UAAU,CAAC,EAAE,KAAK,KAAK;AAClF,cAAQ,WAAW,SAAS,CAAC,GAAG,MAAM,EAAE,KAAK,CAAC;AAAA,IAChD,UAAE;AACA,UAAI,CAAC,QAAS,OAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IAClE;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from "./helpers/scheduler.js";
|
|
5
|
+
function expectSortedByValue(options, label) {
|
|
6
|
+
const values = options.map((option) => option.value);
|
|
7
|
+
expect(values, `${label} should be sorted alphabetically by value`).toEqual(
|
|
8
|
+
[...values].sort((a, b) => a.localeCompare(b))
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
function expectOptionShape(options, label) {
|
|
12
|
+
for (const option of options) {
|
|
13
|
+
expect(typeof option.value, `${label} option value should be a string`).toBe("string");
|
|
14
|
+
expect(typeof option.label, `${label} option label should be a string`).toBe("string");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and commands", () => {
|
|
18
|
+
test("returns sorted queues (incl. scheduler-execution) and a non-empty command list", async ({ request }) => {
|
|
19
|
+
const token = await getAuthToken(request, "admin");
|
|
20
|
+
const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
|
|
21
|
+
expect(response.status()).toBe(200);
|
|
22
|
+
const body = await readJsonSafe(response);
|
|
23
|
+
const queues = body?.queues ?? [];
|
|
24
|
+
const commands = body?.commands ?? [];
|
|
25
|
+
expect(Array.isArray(body?.queues)).toBe(true);
|
|
26
|
+
expect(queues.length).toBeGreaterThan(0);
|
|
27
|
+
expectOptionShape(queues, "queues");
|
|
28
|
+
expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE);
|
|
29
|
+
expectSortedByValue(queues, "queues");
|
|
30
|
+
expect(Array.isArray(body?.commands)).toBe(true);
|
|
31
|
+
expect(commands.length).toBeGreaterThan(0);
|
|
32
|
+
expectOptionShape(commands, "commands");
|
|
33
|
+
expectSortedByValue(commands, "commands");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
//# sourceMappingURL=TC-SCHED-004.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from './helpers/scheduler'\n\ntype TargetOption = { value: string; label: string }\ntype TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }\n\nfunction expectSortedByValue(options: TargetOption[], label: string) {\n const values = options.map((option) => option.value)\n expect(values, `${label} should be sorted alphabetically by value`).toEqual(\n [...values].sort((a, b) => a.localeCompare(b)),\n )\n}\n\nfunction expectOptionShape(options: TargetOption[], label: string) {\n for (const option of options) {\n expect(typeof option.value, `${label} option value should be a string`).toBe('string')\n expect(typeof option.label, `${label} option label should be a string`).toBe('string')\n }\n}\n\n/**\n * TC-SCHED-004: GET /api/scheduler/targets returns available queue names\n * (from module workers) and registered command IDs (from the command\n * registry), both sorted alphabetically by value. Read-only, no fixtures.\n */\ntest.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {\n test('returns sorted queues (incl. scheduler-execution) and a non-empty command list', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<TargetsResponse>(response)\n const queues = body?.queues ?? []\n const commands = body?.commands ?? []\n\n // Queues: non-empty, well-shaped, sorted, and include the known scheduler queue.\n expect(Array.isArray(body?.queues)).toBe(true)\n expect(queues.length).toBeGreaterThan(0)\n expectOptionShape(queues, 'queues')\n expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE)\n expectSortedByValue(queues, 'queues')\n\n // Commands: non-empty, well-shaped, and sorted.\n expect(Array.isArray(body?.commands)).toBe(true)\n expect(commands.length).toBeGreaterThan(0)\n expectOptionShape(commands, 'commands')\n expectSortedByValue(commands, 'commands')\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B,8BAA8B;AAKlE,SAAS,oBAAoB,SAAyB,OAAe;AACnE,QAAM,SAAS,QAAQ,IAAI,CAAC,WAAW,OAAO,KAAK;AACnD,SAAO,QAAQ,GAAG,KAAK,2CAA2C,EAAE;AAAA,IAClE,CAAC,GAAG,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EAC/C;AACF;AAEA,SAAS,kBAAkB,SAAyB,OAAe;AACjE,aAAW,UAAU,SAAS;AAC5B,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AACrF,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AAAA,EACvF;AACF;AAOA,KAAK,SAAS,sEAAsE,MAAM;AACxF,OAAK,kFAAkF,OAAO,EAAE,QAAQ,MAAM;AAC5G,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,UAAM,SAAS,MAAM,UAAU,CAAC;AAChC,UAAM,WAAW,MAAM,YAAY,CAAC;AAGpC,WAAO,MAAM,QAAQ,MAAM,MAAM,CAAC,EAAE,KAAK,IAAI;AAC7C,WAAO,OAAO,MAAM,EAAE,gBAAgB,CAAC;AACvC,sBAAkB,QAAQ,QAAQ;AAClC,WAAO,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAAE,UAAU,yBAAyB;AAC9E,wBAAoB,QAAQ,QAAQ;AAGpC,WAAO,MAAM,QAAQ,MAAM,QAAQ,CAAC,EAAE,KAAK,IAAI;AAC/C,WAAO,SAAS,MAAM,EAAE,gBAAgB,CAAC;AACzC,sBAAkB,UAAU,UAAU;AACtC,wBAAoB,UAAU,UAAU;AAAA,EAC1C,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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_TRIGGER_PATH,
|
|
6
|
+
createScheduleJob,
|
|
7
|
+
deleteScheduleJob,
|
|
8
|
+
uniqueScheduleName
|
|
9
|
+
} from "./helpers/scheduler.js";
|
|
10
|
+
const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || "local") === "async";
|
|
11
|
+
test.describe("TC-SCHED-005: POST /api/scheduler/trigger manual execution", () => {
|
|
12
|
+
test("enqueues an execution job (async) or rejects with async-required (local)", async ({ request }) => {
|
|
13
|
+
const token = await getAuthToken(request, "admin");
|
|
14
|
+
let scheduleId = null;
|
|
15
|
+
try {
|
|
16
|
+
scheduleId = await createScheduleJob(request, token, {
|
|
17
|
+
name: uniqueScheduleName("Trigger-Test"),
|
|
18
|
+
scheduleType: "interval",
|
|
19
|
+
scheduleValue: "1h"
|
|
20
|
+
});
|
|
21
|
+
const response = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
|
|
22
|
+
token,
|
|
23
|
+
data: { id: scheduleId }
|
|
24
|
+
});
|
|
25
|
+
if (isAsyncQueueStrategy) {
|
|
26
|
+
expect(response.status()).toBe(200);
|
|
27
|
+
const body = await readJsonSafe(response);
|
|
28
|
+
expect(body?.ok).toBe(true);
|
|
29
|
+
expect(typeof body?.jobId === "string" && (body?.jobId?.length ?? 0) > 0).toBe(true);
|
|
30
|
+
expect(body?.message ?? "").toMatch(/queued|triggered/i);
|
|
31
|
+
} else {
|
|
32
|
+
expect(response.status()).toBe(400);
|
|
33
|
+
const body = await readJsonSafe(response);
|
|
34
|
+
expect(`${body?.error ?? ""} ${body?.message ?? ""}`).toMatch(/async/i);
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
await deleteScheduleJob(request, token, scheduleId);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
//# sourceMappingURL=TC-SCHED-005.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-005.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_TRIGGER_PATH,\n createScheduleJob,\n deleteScheduleJob,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\n/**\n * TC-SCHED-005: POST /api/scheduler/trigger manually enqueues a schedule for\n * execution. Manual triggers require the async (BullMQ) queue strategy, so the\n * assertion branches on QUEUE_STRATEGY exactly like TC-SCHED-001: async returns\n * 200 + jobId, local returns 400 with an \"async required\" message.\n */\ntest.describe('TC-SCHED-005: POST /api/scheduler/trigger manual execution', () => {\n test('enqueues an execution job (async) or rejects with async-required (local)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createScheduleJob(request, token, {\n name: uniqueScheduleName('Trigger-Test'),\n scheduleType: 'interval',\n scheduleValue: '1h',\n })\n\n const response = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token,\n data: { id: scheduleId },\n })\n\n if (isAsyncQueueStrategy) {\n expect(response.status()).toBe(200)\n const body = await readJsonSafe<{ ok?: boolean; jobId?: string; message?: string }>(response)\n expect(body?.ok).toBe(true)\n expect(typeof body?.jobId === 'string' && (body?.jobId?.length ?? 0) > 0).toBe(true)\n expect(body?.message ?? '').toMatch(/queued|triggered/i)\n } else {\n expect(response.status()).toBe(400)\n const body = await readJsonSafe<{ error?: string; message?: string }>(response)\n expect(`${body?.error ?? ''} ${body?.message ?? ''}`).toMatch(/async/i)\n }\n } finally {\n await deleteScheduleJob(request, token, scheduleId)\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,OACK;AAEP,MAAM,wBAAwB,QAAQ,IAAI,kBAAkB,aAAa;AAQzE,KAAK,SAAS,8DAA8D,MAAM;AAChF,OAAK,4EAA4E,OAAO,EAAE,QAAQ,MAAM;AACtG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAEhC,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,OAAO;AAAA,QACnD,MAAM,mBAAmB,cAAc;AAAA,QACvC,cAAc;AAAA,QACd,eAAe;AAAA,MACjB,CAAC;AAED,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QACzE;AAAA,QACA,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AAED,UAAI,sBAAsB;AACxB,eAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,cAAM,OAAO,MAAM,aAAiE,QAAQ;AAC5F,eAAO,MAAM,EAAE,EAAE,KAAK,IAAI;AAC1B,eAAO,OAAO,MAAM,UAAU,aAAa,MAAM,OAAO,UAAU,KAAK,CAAC,EAAE,KAAK,IAAI;AACnF,eAAO,MAAM,WAAW,EAAE,EAAE,QAAQ,mBAAmB;AAAA,MACzD,OAAO;AACL,eAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,cAAM,OAAO,MAAM,aAAmD,QAAQ;AAC9E,eAAO,GAAG,MAAM,SAAS,EAAE,IAAI,MAAM,WAAW,EAAE,EAAE,EAAE,QAAQ,QAAQ;AAAA,MACxE;AAAA,IACF,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IACpD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import {
|
|
4
|
+
createRoleFixture,
|
|
5
|
+
createUserFixture,
|
|
6
|
+
deleteRoleIfExists,
|
|
7
|
+
deleteUserIfExists,
|
|
8
|
+
setRoleAclFeatures
|
|
9
|
+
} from "@open-mercato/core/helpers/integration/authFixtures";
|
|
10
|
+
import { getTokenContext, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
11
|
+
import { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob, getScheduleJobById } from "./helpers/scheduler.js";
|
|
12
|
+
test.describe("TC-SCHED-006: scheduler.jobs.manage gate on PUT/DELETE", () => {
|
|
13
|
+
test("view-only user gets 403 on PUT and DELETE; schedule stays intact", async ({ request }) => {
|
|
14
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
15
|
+
const { organizationId } = getTokenContext(adminToken);
|
|
16
|
+
expect(organizationId.length, "admin token should carry an organization id").toBeGreaterThan(0);
|
|
17
|
+
const stamp = `${Date.now()}`;
|
|
18
|
+
let roleId = null;
|
|
19
|
+
let userId = null;
|
|
20
|
+
let scheduleId = null;
|
|
21
|
+
try {
|
|
22
|
+
roleId = await createRoleFixture(request, adminToken, { name: `sched-view-only-${stamp}` });
|
|
23
|
+
await setRoleAclFeatures(request, adminToken, { roleId, features: ["scheduler.jobs.view"] });
|
|
24
|
+
const email = `sched-view-only-${stamp}@example.com`;
|
|
25
|
+
const password = "Sched-View-1!";
|
|
26
|
+
userId = await createUserFixture(request, adminToken, {
|
|
27
|
+
email,
|
|
28
|
+
password,
|
|
29
|
+
organizationId,
|
|
30
|
+
roles: [roleId],
|
|
31
|
+
name: "Scheduler View Only"
|
|
32
|
+
});
|
|
33
|
+
const viewToken = await getAuthToken(request, email, password);
|
|
34
|
+
scheduleId = await createScheduleJob(request, adminToken);
|
|
35
|
+
const listResponse = await apiRequest(request, "GET", SCHEDULER_JOBS_PATH, { token: viewToken });
|
|
36
|
+
expect(listResponse.status()).toBe(200);
|
|
37
|
+
const putResponse = await apiRequest(request, "PUT", SCHEDULER_JOBS_PATH, {
|
|
38
|
+
token: viewToken,
|
|
39
|
+
data: { id: scheduleId, name: "Should-Not-Apply" }
|
|
40
|
+
});
|
|
41
|
+
expect(putResponse.status()).toBe(403);
|
|
42
|
+
const putBody = await readJsonSafe(putResponse);
|
|
43
|
+
expect(putBody?.requiredFeatures ?? []).toContain("scheduler.jobs.manage");
|
|
44
|
+
const deleteResponse = await apiRequest(request, "DELETE", SCHEDULER_JOBS_PATH, {
|
|
45
|
+
token: viewToken,
|
|
46
|
+
data: { id: scheduleId }
|
|
47
|
+
});
|
|
48
|
+
expect(deleteResponse.status()).toBe(403);
|
|
49
|
+
const deleteBody = await readJsonSafe(deleteResponse);
|
|
50
|
+
expect(deleteBody?.requiredFeatures ?? []).toContain("scheduler.jobs.manage");
|
|
51
|
+
const after = await getScheduleJobById(request, adminToken, scheduleId);
|
|
52
|
+
expect(after).not.toBeNull();
|
|
53
|
+
expect(after.name).not.toBe("Should-Not-Apply");
|
|
54
|
+
} finally {
|
|
55
|
+
await deleteScheduleJob(request, adminToken, scheduleId);
|
|
56
|
+
await deleteUserIfExists(request, adminToken, userId);
|
|
57
|
+
await deleteRoleIfExists(request, adminToken, roleId);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
//# sourceMappingURL=TC-SCHED-006.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-006.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport {\n createRoleFixture,\n createUserFixture,\n deleteRoleIfExists,\n deleteUserIfExists,\n setRoleAclFeatures,\n} from '@open-mercato/core/helpers/integration/authFixtures'\nimport { getTokenContext, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob, getScheduleJobById } from './helpers/scheduler'\n\ntype ForbiddenBody = { error?: string; requiredFeatures?: string[] }\n\n/**\n * TC-SCHED-006: PUT and DELETE on /api/scheduler/jobs are gated by\n * `scheduler.jobs.manage`. A user holding only `scheduler.jobs.view` can read\n * schedules but is denied with 403 on both mutating verbs, and a failed attempt\n * leaves the schedule untouched.\n */\ntest.describe('TC-SCHED-006: scheduler.jobs.manage gate on PUT/DELETE', () => {\n test('view-only user gets 403 on PUT and DELETE; schedule stays intact', async ({ request }) => {\n const adminToken = await getAuthToken(request, 'admin')\n const { organizationId } = getTokenContext(adminToken)\n expect(organizationId.length, 'admin token should carry an organization id').toBeGreaterThan(0)\n\n const stamp = `${Date.now()}`\n let roleId: string | null = null\n let userId: string | null = null\n let scheduleId: string | null = null\n\n try {\n // A role that can view schedules but not manage them.\n roleId = await createRoleFixture(request, adminToken, { name: `sched-view-only-${stamp}` })\n await setRoleAclFeatures(request, adminToken, { roleId, features: ['scheduler.jobs.view'] })\n\n const email = `sched-view-only-${stamp}@example.com`\n // Password must satisfy the default policy (min length + digit + uppercase + special).\n const password = 'Sched-View-1!'\n userId = await createUserFixture(request, adminToken, {\n email,\n password,\n organizationId,\n roles: [roleId],\n name: 'Scheduler View Only',\n })\n const viewToken = await getAuthToken(request, email, password)\n\n scheduleId = await createScheduleJob(request, adminToken)\n\n // Sanity: the view-only user authenticates and CAN list (has the view feature),\n // so the 403s below are specifically about the missing manage feature.\n const listResponse = await apiRequest(request, 'GET', SCHEDULER_JOBS_PATH, { token: viewToken })\n expect(listResponse.status()).toBe(200)\n\n // PUT denied\n const putResponse = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {\n token: viewToken,\n data: { id: scheduleId, name: 'Should-Not-Apply' },\n })\n expect(putResponse.status()).toBe(403)\n const putBody = await readJsonSafe<ForbiddenBody>(putResponse)\n expect(putBody?.requiredFeatures ?? []).toContain('scheduler.jobs.manage')\n\n // DELETE denied\n const deleteResponse = await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, {\n token: viewToken,\n data: { id: scheduleId },\n })\n expect(deleteResponse.status()).toBe(403)\n const deleteBody = await readJsonSafe<ForbiddenBody>(deleteResponse)\n expect(deleteBody?.requiredFeatures ?? []).toContain('scheduler.jobs.manage')\n\n // The schedule is unchanged and still present (admin can read it).\n const after = await getScheduleJobById(request, adminToken, scheduleId)\n expect(after).not.toBeNull()\n expect(after!.name).not.toBe('Should-Not-Apply')\n } finally {\n await deleteScheduleJob(request, adminToken, scheduleId)\n await deleteUserIfExists(request, adminToken, userId)\n await deleteRoleIfExists(request, adminToken, roleId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,iBAAiB,oBAAoB;AAC9C,SAAS,qBAAqB,mBAAmB,mBAAmB,0BAA0B;AAU9F,KAAK,SAAS,0DAA0D,MAAM;AAC5E,OAAK,oEAAoE,OAAO,EAAE,QAAQ,MAAM;AAC9F,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,UAAM,EAAE,eAAe,IAAI,gBAAgB,UAAU;AACrD,WAAO,eAAe,QAAQ,6CAA6C,EAAE,gBAAgB,CAAC;AAE9F,UAAM,QAAQ,GAAG,KAAK,IAAI,CAAC;AAC3B,QAAI,SAAwB;AAC5B,QAAI,SAAwB;AAC5B,QAAI,aAA4B;AAEhC,QAAI;AAEF,eAAS,MAAM,kBAAkB,SAAS,YAAY,EAAE,MAAM,mBAAmB,KAAK,GAAG,CAAC;AAC1F,YAAM,mBAAmB,SAAS,YAAY,EAAE,QAAQ,UAAU,CAAC,qBAAqB,EAAE,CAAC;AAE3F,YAAM,QAAQ,mBAAmB,KAAK;AAEtC,YAAM,WAAW;AACjB,eAAS,MAAM,kBAAkB,SAAS,YAAY;AAAA,QACpD;AAAA,QACA;AAAA,QACA;AAAA,QACA,OAAO,CAAC,MAAM;AAAA,QACd,MAAM;AAAA,MACR,CAAC;AACD,YAAM,YAAY,MAAM,aAAa,SAAS,OAAO,QAAQ;AAE7D,mBAAa,MAAM,kBAAkB,SAAS,UAAU;AAIxD,YAAM,eAAe,MAAM,WAAW,SAAS,OAAO,qBAAqB,EAAE,OAAO,UAAU,CAAC;AAC/F,aAAO,aAAa,OAAO,CAAC,EAAE,KAAK,GAAG;AAGtC,YAAM,cAAc,MAAM,WAAW,SAAS,OAAO,qBAAqB;AAAA,QACxE,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,YAAY,MAAM,mBAAmB;AAAA,MACnD,CAAC;AACD,aAAO,YAAY,OAAO,CAAC,EAAE,KAAK,GAAG;AACrC,YAAM,UAAU,MAAM,aAA4B,WAAW;AAC7D,aAAO,SAAS,oBAAoB,CAAC,CAAC,EAAE,UAAU,uBAAuB;AAGzE,YAAM,iBAAiB,MAAM,WAAW,SAAS,UAAU,qBAAqB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD,aAAO,eAAe,OAAO,CAAC,EAAE,KAAK,GAAG;AACxC,YAAM,aAAa,MAAM,aAA4B,cAAc;AACnE,aAAO,YAAY,oBAAoB,CAAC,CAAC,EAAE,UAAU,uBAAuB;AAG5E,YAAM,QAAQ,MAAM,mBAAmB,SAAS,YAAY,UAAU;AACtE,aAAO,KAAK,EAAE,IAAI,SAAS;AAC3B,aAAO,MAAO,IAAI,EAAE,IAAI,KAAK,kBAAkB;AAAA,IACjD,UAAE;AACA,YAAM,kBAAkB,SAAS,YAAY,UAAU;AACvD,YAAM,mBAAmB,SAAS,YAAY,MAAM;AACpD,YAAM,mBAAmB,SAAS,YAAY,MAAM;AAAA,IACtD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from "./helpers/scheduler.js";
|
|
5
|
+
const invalidCases = [
|
|
6
|
+
{ label: "cron: not a cron expression", scheduleType: "cron", scheduleValue: "not-a-cron" },
|
|
7
|
+
{ label: "cron: too few fields", scheduleType: "cron", scheduleValue: "* * *" },
|
|
8
|
+
{ label: "interval: unsupported unit", scheduleType: "interval", scheduleValue: "15x" },
|
|
9
|
+
{ label: "interval: missing unit", scheduleType: "interval", scheduleValue: "99" }
|
|
10
|
+
];
|
|
11
|
+
test.describe("TC-SCHED-007: POST /api/scheduler/jobs validates schedule value format", () => {
|
|
12
|
+
for (const testCase of invalidCases) {
|
|
13
|
+
test(`rejects ${testCase.label} with 400 on scheduleValue`, async ({ request }) => {
|
|
14
|
+
const token = await getAuthToken(request, "admin");
|
|
15
|
+
const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, {
|
|
16
|
+
token,
|
|
17
|
+
data: {
|
|
18
|
+
name: uniqueScheduleName("Invalid Schedule Value"),
|
|
19
|
+
scopeType: "organization",
|
|
20
|
+
scheduleType: testCase.scheduleType,
|
|
21
|
+
scheduleValue: testCase.scheduleValue,
|
|
22
|
+
timezone: "UTC",
|
|
23
|
+
targetType: "queue",
|
|
24
|
+
targetQueue: SCHEDULER_EXECUTION_QUEUE,
|
|
25
|
+
isEnabled: true,
|
|
26
|
+
sourceType: "user"
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
expect(response.status(), `${testCase.label} should be rejected with 400`).toBe(400);
|
|
30
|
+
const body = await readJsonSafe(response);
|
|
31
|
+
const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join("."));
|
|
32
|
+
expect(issuePaths, `${testCase.label} error should target scheduleValue`).toContain("scheduleValue");
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
//# sourceMappingURL=TC-SCHED-007.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from './helpers/scheduler'\n\ntype ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }\n\n/**\n * TC-SCHED-007: POST /api/scheduler/jobs rejects invalid cron and interval\n * `scheduleValue`s with 400 and an error scoped to the `scheduleValue` path.\n *\n * Interval format is `<number><unit>` with unit in s|m|h|d, so the inputs below\n * are genuinely malformed (the issue's \"99h\"/\"0m\" drafts are actually valid).\n * All cases are rejected at validation, so no records are created and no\n * 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]\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;AAanF,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;AAC5F;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
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
import { apiRequest } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
const SCHEDULER_JOBS_PATH = "/api/scheduler/jobs";
|
|
5
|
+
const SCHEDULER_TARGETS_PATH = "/api/scheduler/targets";
|
|
6
|
+
const SCHEDULER_TRIGGER_PATH = "/api/scheduler/trigger";
|
|
7
|
+
const SCHEDULER_EXECUTION_QUEUE = "scheduler-execution";
|
|
8
|
+
let sequence = 0;
|
|
9
|
+
function uniqueScheduleName(prefix) {
|
|
10
|
+
sequence += 1;
|
|
11
|
+
return `${prefix} ${Date.now()}-${sequence}`;
|
|
12
|
+
}
|
|
13
|
+
async function createScheduleJob(request, token, overrides = {}) {
|
|
14
|
+
const targetType = overrides.targetType ?? "queue";
|
|
15
|
+
const data = {
|
|
16
|
+
name: overrides.name ?? uniqueScheduleName("Integration Scheduler"),
|
|
17
|
+
description: overrides.description ?? "Created by scheduler integration tests",
|
|
18
|
+
scopeType: overrides.scopeType ?? "organization",
|
|
19
|
+
scheduleType: overrides.scheduleType ?? "interval",
|
|
20
|
+
scheduleValue: overrides.scheduleValue ?? "15m",
|
|
21
|
+
timezone: overrides.timezone ?? "UTC",
|
|
22
|
+
targetType,
|
|
23
|
+
targetPayload: overrides.targetPayload ?? { source: "integration-test" },
|
|
24
|
+
isEnabled: overrides.isEnabled ?? true,
|
|
25
|
+
sourceType: overrides.sourceType ?? "user"
|
|
26
|
+
};
|
|
27
|
+
if (targetType === "command") {
|
|
28
|
+
data.targetCommand = overrides.targetCommand;
|
|
29
|
+
} else {
|
|
30
|
+
data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE;
|
|
31
|
+
}
|
|
32
|
+
const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, { token, data });
|
|
33
|
+
const body = await readJsonSafe(response);
|
|
34
|
+
expect(response.status(), "POST /api/scheduler/jobs should return 201").toBe(201);
|
|
35
|
+
const id = body?.id;
|
|
36
|
+
expect(typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id), "create response should include a UUID id").toBe(true);
|
|
37
|
+
return String(id);
|
|
38
|
+
}
|
|
39
|
+
async function getScheduleJobById(request, token, id) {
|
|
40
|
+
const response = await apiRequest(request, "GET", `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}`, { token });
|
|
41
|
+
expect(response.status(), "GET /api/scheduler/jobs?id should return 200").toBe(200);
|
|
42
|
+
const body = await readJsonSafe(response);
|
|
43
|
+
return (body?.items ?? []).find((item) => item.id === id) ?? null;
|
|
44
|
+
}
|
|
45
|
+
async function deleteScheduleJob(request, token, id) {
|
|
46
|
+
if (!id) return;
|
|
47
|
+
await apiRequest(request, "DELETE", SCHEDULER_JOBS_PATH, { token, data: { id } }).catch(() => null);
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
SCHEDULER_EXECUTION_QUEUE,
|
|
51
|
+
SCHEDULER_JOBS_PATH,
|
|
52
|
+
SCHEDULER_TARGETS_PATH,
|
|
53
|
+
SCHEDULER_TRIGGER_PATH,
|
|
54
|
+
createScheduleJob,
|
|
55
|
+
deleteScheduleJob,
|
|
56
|
+
getScheduleJobById,
|
|
57
|
+
uniqueScheduleName
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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. */\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
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/scheduler",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.4620.1.c20bc7e4bb",
|
|
4
4
|
"description": "Database-managed scheduled jobs with admin UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@open-mercato/events": "0.6.5-develop.
|
|
62
|
-
"@open-mercato/queue": "0.6.5-develop.
|
|
61
|
+
"@open-mercato/events": "0.6.5-develop.4620.1.c20bc7e4bb",
|
|
62
|
+
"@open-mercato/queue": "0.6.5-develop.4620.1.c20bc7e4bb",
|
|
63
63
|
"cron-parser": "^5.5.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@mikro-orm/core": "^7.0.14",
|
|
67
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
67
|
+
"@open-mercato/shared": "0.6.5-develop.4620.1.c20bc7e4bb",
|
|
68
68
|
"bullmq": "^5.0.0",
|
|
69
69
|
"ioredis": "^5.0.0",
|
|
70
70
|
"zod": ">=3.23.0"
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
81
|
+
"@open-mercato/shared": "0.6.5-develop.4620.1.c20bc7e4bb",
|
|
82
82
|
"@types/jest": "^30.0.0",
|
|
83
83
|
"@types/node": "^25.9.1",
|
|
84
84
|
"jest": "^30.4.2",
|
|
@@ -0,0 +1,63 @@
|
|
|
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_JOBS_PATH,
|
|
6
|
+
createScheduleJob,
|
|
7
|
+
deleteScheduleJob,
|
|
8
|
+
getScheduleJobById,
|
|
9
|
+
uniqueScheduleName,
|
|
10
|
+
} from './helpers/scheduler'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TC-SCHED-002: PUT /api/scheduler/jobs updates schedule fields correctly
|
|
14
|
+
*
|
|
15
|
+
* Note: scheduleUpdateSchema requires `scheduleType` whenever `scheduleValue`
|
|
16
|
+
* changes, so the update payload sends both (the issue's draft omitted the
|
|
17
|
+
* type — the route rejects that with 400). This asserts the real contract.
|
|
18
|
+
*/
|
|
19
|
+
test.describe('TC-SCHED-002: PUT /api/scheduler/jobs updates schedule fields', () => {
|
|
20
|
+
test('updates name and scheduleValue while preserving identity and scope', async ({ request }) => {
|
|
21
|
+
const token = await getAuthToken(request, 'admin')
|
|
22
|
+
let scheduleId: string | null = null
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
scheduleId = await createScheduleJob(request, token, {
|
|
26
|
+
name: uniqueScheduleName('Sched Update'),
|
|
27
|
+
scheduleType: 'interval',
|
|
28
|
+
scheduleValue: '15m',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const original = await getScheduleJobById(request, token, scheduleId)
|
|
32
|
+
expect(original).not.toBeNull()
|
|
33
|
+
expect(original!.scheduleValue).toBe('15m')
|
|
34
|
+
|
|
35
|
+
const updateResponse = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {
|
|
36
|
+
token,
|
|
37
|
+
data: { id: scheduleId, name: 'Updated-Test-1', scheduleType: 'interval', scheduleValue: '30m' },
|
|
38
|
+
})
|
|
39
|
+
expect(updateResponse.status()).toBe(200)
|
|
40
|
+
const updateBody = await readJsonSafe<{ ok?: boolean }>(updateResponse)
|
|
41
|
+
expect(updateBody?.ok).toBe(true)
|
|
42
|
+
|
|
43
|
+
const updated = await getScheduleJobById(request, token, scheduleId)
|
|
44
|
+
expect(updated).not.toBeNull()
|
|
45
|
+
// Mutated fields persisted
|
|
46
|
+
expect(updated!.name).toBe('Updated-Test-1')
|
|
47
|
+
expect(updated!.scheduleValue).toBe('30m')
|
|
48
|
+
// Identity and scope unchanged
|
|
49
|
+
expect(updated!.id).toBe(scheduleId)
|
|
50
|
+
expect(updated!.tenantId).toBe(original!.tenantId)
|
|
51
|
+
expect(updated!.organizationId).toBe(original!.organizationId)
|
|
52
|
+
expect(updated!.targetQueue).toBe(original!.targetQueue)
|
|
53
|
+
// updatedAt advances past the original creation timestamp
|
|
54
|
+
if (original!.createdAt && updated!.updatedAt) {
|
|
55
|
+
expect(new Date(updated!.updatedAt).getTime()).toBeGreaterThanOrEqual(
|
|
56
|
+
new Date(original!.createdAt).getTime(),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
await deleteScheduleJob(request, token, scheduleId)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob, getScheduleJobById } from './helpers/scheduler'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TC-SCHED-003: DELETE /api/scheduler/jobs soft-deletes a schedule and
|
|
8
|
+
* prevents re-listing. The list endpoint keeps returning 200 (not 404) with
|
|
9
|
+
* the soft-deleted record filtered out by the `deletedAt` soft-delete field.
|
|
10
|
+
*/
|
|
11
|
+
test.describe('TC-SCHED-003: DELETE /api/scheduler/jobs soft-deletes a schedule', () => {
|
|
12
|
+
test('removes the schedule from listings and returns 200 (not 404) afterwards', async ({ request }) => {
|
|
13
|
+
const token = await getAuthToken(request, 'admin')
|
|
14
|
+
let scheduleId: string | null = null
|
|
15
|
+
let deleted = false
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
scheduleId = await createScheduleJob(request, token)
|
|
19
|
+
|
|
20
|
+
const present = await getScheduleJobById(request, token, scheduleId)
|
|
21
|
+
expect(present).not.toBeNull()
|
|
22
|
+
|
|
23
|
+
const deleteResponse = await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, {
|
|
24
|
+
token,
|
|
25
|
+
data: { id: scheduleId },
|
|
26
|
+
})
|
|
27
|
+
expect(deleteResponse.status()).toBe(200)
|
|
28
|
+
const deleteBody = await readJsonSafe<{ ok?: boolean }>(deleteResponse)
|
|
29
|
+
expect(deleteBody?.ok).toBe(true)
|
|
30
|
+
deleted = true
|
|
31
|
+
|
|
32
|
+
// Soft delete: list still returns 200 with an (empty) items array, never 404.
|
|
33
|
+
const afterResponse = await apiRequest(
|
|
34
|
+
request,
|
|
35
|
+
'GET',
|
|
36
|
+
`${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(scheduleId)}`,
|
|
37
|
+
{ token },
|
|
38
|
+
)
|
|
39
|
+
expect(afterResponse.status()).toBe(200)
|
|
40
|
+
const afterBody = await readJsonSafe<{ items?: Array<{ id: string }> }>(afterResponse)
|
|
41
|
+
expect(Array.isArray(afterBody?.items)).toBe(true)
|
|
42
|
+
expect((afterBody?.items ?? []).some((item) => item.id === scheduleId)).toBe(false)
|
|
43
|
+
expect((afterBody?.items ?? []).length).toBe(0)
|
|
44
|
+
} finally {
|
|
45
|
+
if (!deleted) await deleteScheduleJob(request, token, scheduleId)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from './helpers/scheduler'
|
|
5
|
+
|
|
6
|
+
type TargetOption = { value: string; label: string }
|
|
7
|
+
type TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }
|
|
8
|
+
|
|
9
|
+
function expectSortedByValue(options: TargetOption[], label: string) {
|
|
10
|
+
const values = options.map((option) => option.value)
|
|
11
|
+
expect(values, `${label} should be sorted alphabetically by value`).toEqual(
|
|
12
|
+
[...values].sort((a, b) => a.localeCompare(b)),
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function expectOptionShape(options: TargetOption[], label: string) {
|
|
17
|
+
for (const option of options) {
|
|
18
|
+
expect(typeof option.value, `${label} option value should be a string`).toBe('string')
|
|
19
|
+
expect(typeof option.label, `${label} option label should be a string`).toBe('string')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* TC-SCHED-004: GET /api/scheduler/targets returns available queue names
|
|
25
|
+
* (from module workers) and registered command IDs (from the command
|
|
26
|
+
* registry), both sorted alphabetically by value. Read-only, no fixtures.
|
|
27
|
+
*/
|
|
28
|
+
test.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {
|
|
29
|
+
test('returns sorted queues (incl. scheduler-execution) and a non-empty command list', async ({ request }) => {
|
|
30
|
+
const token = await getAuthToken(request, 'admin')
|
|
31
|
+
|
|
32
|
+
const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })
|
|
33
|
+
expect(response.status()).toBe(200)
|
|
34
|
+
|
|
35
|
+
const body = await readJsonSafe<TargetsResponse>(response)
|
|
36
|
+
const queues = body?.queues ?? []
|
|
37
|
+
const commands = body?.commands ?? []
|
|
38
|
+
|
|
39
|
+
// Queues: non-empty, well-shaped, sorted, and include the known scheduler queue.
|
|
40
|
+
expect(Array.isArray(body?.queues)).toBe(true)
|
|
41
|
+
expect(queues.length).toBeGreaterThan(0)
|
|
42
|
+
expectOptionShape(queues, 'queues')
|
|
43
|
+
expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE)
|
|
44
|
+
expectSortedByValue(queues, 'queues')
|
|
45
|
+
|
|
46
|
+
// Commands: non-empty, well-shaped, and sorted.
|
|
47
|
+
expect(Array.isArray(body?.commands)).toBe(true)
|
|
48
|
+
expect(commands.length).toBeGreaterThan(0)
|
|
49
|
+
expectOptionShape(commands, 'commands')
|
|
50
|
+
expectSortedByValue(commands, 'commands')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
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_TRIGGER_PATH,
|
|
6
|
+
createScheduleJob,
|
|
7
|
+
deleteScheduleJob,
|
|
8
|
+
uniqueScheduleName,
|
|
9
|
+
} from './helpers/scheduler'
|
|
10
|
+
|
|
11
|
+
const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TC-SCHED-005: POST /api/scheduler/trigger manually enqueues a schedule for
|
|
15
|
+
* execution. Manual triggers require the async (BullMQ) queue strategy, so the
|
|
16
|
+
* assertion branches on QUEUE_STRATEGY exactly like TC-SCHED-001: async returns
|
|
17
|
+
* 200 + jobId, local returns 400 with an "async required" message.
|
|
18
|
+
*/
|
|
19
|
+
test.describe('TC-SCHED-005: POST /api/scheduler/trigger manual execution', () => {
|
|
20
|
+
test('enqueues an execution job (async) or rejects with async-required (local)', async ({ request }) => {
|
|
21
|
+
const token = await getAuthToken(request, 'admin')
|
|
22
|
+
let scheduleId: string | null = null
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
scheduleId = await createScheduleJob(request, token, {
|
|
26
|
+
name: uniqueScheduleName('Trigger-Test'),
|
|
27
|
+
scheduleType: 'interval',
|
|
28
|
+
scheduleValue: '1h',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const response = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
|
|
32
|
+
token,
|
|
33
|
+
data: { id: scheduleId },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
if (isAsyncQueueStrategy) {
|
|
37
|
+
expect(response.status()).toBe(200)
|
|
38
|
+
const body = await readJsonSafe<{ ok?: boolean; jobId?: string; message?: string }>(response)
|
|
39
|
+
expect(body?.ok).toBe(true)
|
|
40
|
+
expect(typeof body?.jobId === 'string' && (body?.jobId?.length ?? 0) > 0).toBe(true)
|
|
41
|
+
expect(body?.message ?? '').toMatch(/queued|triggered/i)
|
|
42
|
+
} else {
|
|
43
|
+
expect(response.status()).toBe(400)
|
|
44
|
+
const body = await readJsonSafe<{ error?: string; message?: string }>(response)
|
|
45
|
+
expect(`${body?.error ?? ''} ${body?.message ?? ''}`).toMatch(/async/i)
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
await deleteScheduleJob(request, token, scheduleId)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import {
|
|
4
|
+
createRoleFixture,
|
|
5
|
+
createUserFixture,
|
|
6
|
+
deleteRoleIfExists,
|
|
7
|
+
deleteUserIfExists,
|
|
8
|
+
setRoleAclFeatures,
|
|
9
|
+
} from '@open-mercato/core/helpers/integration/authFixtures'
|
|
10
|
+
import { getTokenContext, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
11
|
+
import { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob, getScheduleJobById } from './helpers/scheduler'
|
|
12
|
+
|
|
13
|
+
type ForbiddenBody = { error?: string; requiredFeatures?: string[] }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TC-SCHED-006: PUT and DELETE on /api/scheduler/jobs are gated by
|
|
17
|
+
* `scheduler.jobs.manage`. A user holding only `scheduler.jobs.view` can read
|
|
18
|
+
* schedules but is denied with 403 on both mutating verbs, and a failed attempt
|
|
19
|
+
* leaves the schedule untouched.
|
|
20
|
+
*/
|
|
21
|
+
test.describe('TC-SCHED-006: scheduler.jobs.manage gate on PUT/DELETE', () => {
|
|
22
|
+
test('view-only user gets 403 on PUT and DELETE; schedule stays intact', async ({ request }) => {
|
|
23
|
+
const adminToken = await getAuthToken(request, 'admin')
|
|
24
|
+
const { organizationId } = getTokenContext(adminToken)
|
|
25
|
+
expect(organizationId.length, 'admin token should carry an organization id').toBeGreaterThan(0)
|
|
26
|
+
|
|
27
|
+
const stamp = `${Date.now()}`
|
|
28
|
+
let roleId: string | null = null
|
|
29
|
+
let userId: string | null = null
|
|
30
|
+
let scheduleId: string | null = null
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// A role that can view schedules but not manage them.
|
|
34
|
+
roleId = await createRoleFixture(request, adminToken, { name: `sched-view-only-${stamp}` })
|
|
35
|
+
await setRoleAclFeatures(request, adminToken, { roleId, features: ['scheduler.jobs.view'] })
|
|
36
|
+
|
|
37
|
+
const email = `sched-view-only-${stamp}@example.com`
|
|
38
|
+
// Password must satisfy the default policy (min length + digit + uppercase + special).
|
|
39
|
+
const password = 'Sched-View-1!'
|
|
40
|
+
userId = await createUserFixture(request, adminToken, {
|
|
41
|
+
email,
|
|
42
|
+
password,
|
|
43
|
+
organizationId,
|
|
44
|
+
roles: [roleId],
|
|
45
|
+
name: 'Scheduler View Only',
|
|
46
|
+
})
|
|
47
|
+
const viewToken = await getAuthToken(request, email, password)
|
|
48
|
+
|
|
49
|
+
scheduleId = await createScheduleJob(request, adminToken)
|
|
50
|
+
|
|
51
|
+
// Sanity: the view-only user authenticates and CAN list (has the view feature),
|
|
52
|
+
// so the 403s below are specifically about the missing manage feature.
|
|
53
|
+
const listResponse = await apiRequest(request, 'GET', SCHEDULER_JOBS_PATH, { token: viewToken })
|
|
54
|
+
expect(listResponse.status()).toBe(200)
|
|
55
|
+
|
|
56
|
+
// PUT denied
|
|
57
|
+
const putResponse = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {
|
|
58
|
+
token: viewToken,
|
|
59
|
+
data: { id: scheduleId, name: 'Should-Not-Apply' },
|
|
60
|
+
})
|
|
61
|
+
expect(putResponse.status()).toBe(403)
|
|
62
|
+
const putBody = await readJsonSafe<ForbiddenBody>(putResponse)
|
|
63
|
+
expect(putBody?.requiredFeatures ?? []).toContain('scheduler.jobs.manage')
|
|
64
|
+
|
|
65
|
+
// DELETE denied
|
|
66
|
+
const deleteResponse = await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, {
|
|
67
|
+
token: viewToken,
|
|
68
|
+
data: { id: scheduleId },
|
|
69
|
+
})
|
|
70
|
+
expect(deleteResponse.status()).toBe(403)
|
|
71
|
+
const deleteBody = await readJsonSafe<ForbiddenBody>(deleteResponse)
|
|
72
|
+
expect(deleteBody?.requiredFeatures ?? []).toContain('scheduler.jobs.manage')
|
|
73
|
+
|
|
74
|
+
// The schedule is unchanged and still present (admin can read it).
|
|
75
|
+
const after = await getScheduleJobById(request, adminToken, scheduleId)
|
|
76
|
+
expect(after).not.toBeNull()
|
|
77
|
+
expect(after!.name).not.toBe('Should-Not-Apply')
|
|
78
|
+
} finally {
|
|
79
|
+
await deleteScheduleJob(request, adminToken, scheduleId)
|
|
80
|
+
await deleteUserIfExists(request, adminToken, userId)
|
|
81
|
+
await deleteRoleIfExists(request, adminToken, roleId)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
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 { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from './helpers/scheduler'
|
|
5
|
+
|
|
6
|
+
type ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TC-SCHED-007: POST /api/scheduler/jobs rejects invalid cron and interval
|
|
10
|
+
* `scheduleValue`s with 400 and an error scoped to the `scheduleValue` path.
|
|
11
|
+
*
|
|
12
|
+
* Interval format is `<number><unit>` with unit in s|m|h|d, so the inputs below
|
|
13
|
+
* are genuinely malformed (the issue's "99h"/"0m" drafts are actually valid).
|
|
14
|
+
* All cases are rejected at validation, so no records are created and no
|
|
15
|
+
* teardown is required.
|
|
16
|
+
*/
|
|
17
|
+
const invalidCases = [
|
|
18
|
+
{ label: 'cron: not a cron expression', scheduleType: 'cron' as const, scheduleValue: 'not-a-cron' },
|
|
19
|
+
{ label: 'cron: too few fields', scheduleType: 'cron' as const, scheduleValue: '* * *' },
|
|
20
|
+
{ label: 'interval: unsupported unit', scheduleType: 'interval' as const, scheduleValue: '15x' },
|
|
21
|
+
{ label: 'interval: missing unit', scheduleType: 'interval' as const, scheduleValue: '99' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
test.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value format', () => {
|
|
25
|
+
for (const testCase of invalidCases) {
|
|
26
|
+
test(`rejects ${testCase.label} with 400 on scheduleValue`, async ({ request }) => {
|
|
27
|
+
const token = await getAuthToken(request, 'admin')
|
|
28
|
+
|
|
29
|
+
const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {
|
|
30
|
+
token,
|
|
31
|
+
data: {
|
|
32
|
+
name: uniqueScheduleName('Invalid Schedule Value'),
|
|
33
|
+
scopeType: 'organization',
|
|
34
|
+
scheduleType: testCase.scheduleType,
|
|
35
|
+
scheduleValue: testCase.scheduleValue,
|
|
36
|
+
timezone: 'UTC',
|
|
37
|
+
targetType: 'queue',
|
|
38
|
+
targetQueue: SCHEDULER_EXECUTION_QUEUE,
|
|
39
|
+
isEnabled: true,
|
|
40
|
+
sourceType: 'user',
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(response.status(), `${testCase.label} should be rejected with 400`).toBe(400)
|
|
45
|
+
const body = await readJsonSafe<ValidationError>(response)
|
|
46
|
+
const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))
|
|
47
|
+
expect(issuePaths, `${testCase.label} error should target scheduleValue`).toContain('scheduleValue')
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { expect, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { apiRequest } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
export const SCHEDULER_JOBS_PATH = '/api/scheduler/jobs'
|
|
6
|
+
export const SCHEDULER_TARGETS_PATH = '/api/scheduler/targets'
|
|
7
|
+
export const SCHEDULER_TRIGGER_PATH = '/api/scheduler/trigger'
|
|
8
|
+
|
|
9
|
+
/** Queue registered by the scheduler module's execute-schedule worker. */
|
|
10
|
+
export const SCHEDULER_EXECUTION_QUEUE = 'scheduler-execution'
|
|
11
|
+
|
|
12
|
+
export type SchedulerJob = {
|
|
13
|
+
id: string
|
|
14
|
+
name: string
|
|
15
|
+
description: string | null
|
|
16
|
+
scopeType: 'system' | 'organization' | 'tenant'
|
|
17
|
+
organizationId: string | null
|
|
18
|
+
tenantId: string | null
|
|
19
|
+
scheduleType: 'cron' | 'interval'
|
|
20
|
+
scheduleValue: string
|
|
21
|
+
timezone: string
|
|
22
|
+
targetType: 'queue' | 'command'
|
|
23
|
+
targetQueue: string | null
|
|
24
|
+
targetCommand: string | null
|
|
25
|
+
isEnabled: boolean
|
|
26
|
+
createdAt: string | null
|
|
27
|
+
updatedAt: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CreateScheduleOverrides = {
|
|
31
|
+
name?: string
|
|
32
|
+
description?: string | null
|
|
33
|
+
scopeType?: 'system' | 'organization' | 'tenant'
|
|
34
|
+
scheduleType?: 'cron' | 'interval'
|
|
35
|
+
scheduleValue?: string
|
|
36
|
+
timezone?: string
|
|
37
|
+
targetType?: 'queue' | 'command'
|
|
38
|
+
targetQueue?: string
|
|
39
|
+
targetCommand?: string
|
|
40
|
+
targetPayload?: Record<string, unknown>
|
|
41
|
+
isEnabled?: boolean
|
|
42
|
+
sourceType?: 'user' | 'module'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let sequence = 0
|
|
46
|
+
|
|
47
|
+
/** Stable-ish unique label so concurrent fixtures never collide on name. */
|
|
48
|
+
export function uniqueScheduleName(prefix: string): string {
|
|
49
|
+
sequence += 1
|
|
50
|
+
return `${prefix} ${Date.now()}-${sequence}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create an organization-scoped queue-target schedule via the API and return its id.
|
|
55
|
+
* Org/tenant are derived server-side from the caller's auth context, so the caller
|
|
56
|
+
* only needs an admin (or otherwise scheduler.jobs.manage) token.
|
|
57
|
+
*/
|
|
58
|
+
export async function createScheduleJob(
|
|
59
|
+
request: APIRequestContext,
|
|
60
|
+
token: string,
|
|
61
|
+
overrides: CreateScheduleOverrides = {},
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const targetType = overrides.targetType ?? 'queue'
|
|
64
|
+
const data: Record<string, unknown> = {
|
|
65
|
+
name: overrides.name ?? uniqueScheduleName('Integration Scheduler'),
|
|
66
|
+
description: overrides.description ?? 'Created by scheduler integration tests',
|
|
67
|
+
scopeType: overrides.scopeType ?? 'organization',
|
|
68
|
+
scheduleType: overrides.scheduleType ?? 'interval',
|
|
69
|
+
scheduleValue: overrides.scheduleValue ?? '15m',
|
|
70
|
+
timezone: overrides.timezone ?? 'UTC',
|
|
71
|
+
targetType,
|
|
72
|
+
targetPayload: overrides.targetPayload ?? { source: 'integration-test' },
|
|
73
|
+
isEnabled: overrides.isEnabled ?? true,
|
|
74
|
+
sourceType: overrides.sourceType ?? 'user',
|
|
75
|
+
}
|
|
76
|
+
if (targetType === 'command') {
|
|
77
|
+
data.targetCommand = overrides.targetCommand
|
|
78
|
+
} else {
|
|
79
|
+
data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, { token, data })
|
|
83
|
+
const body = await readJsonSafe<{ id?: string }>(response)
|
|
84
|
+
expect(response.status(), 'POST /api/scheduler/jobs should return 201').toBe(201)
|
|
85
|
+
const id = body?.id
|
|
86
|
+
expect(typeof id === 'string' && /^[0-9a-f-]{36}$/i.test(id), 'create response should include a UUID id').toBe(true)
|
|
87
|
+
return String(id)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fetch a single schedule by id from the list endpoint; null when absent (e.g. soft-deleted). */
|
|
91
|
+
export async function getScheduleJobById(
|
|
92
|
+
request: APIRequestContext,
|
|
93
|
+
token: string,
|
|
94
|
+
id: string,
|
|
95
|
+
): Promise<SchedulerJob | null> {
|
|
96
|
+
const response = await apiRequest(request, 'GET', `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}`, { token })
|
|
97
|
+
expect(response.status(), 'GET /api/scheduler/jobs?id should return 200').toBe(200)
|
|
98
|
+
const body = await readJsonSafe<{ items?: SchedulerJob[] }>(response)
|
|
99
|
+
return (body?.items ?? []).find((item) => item.id === id) ?? null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Best-effort cleanup; swallows errors so teardown never masks the real assertion. */
|
|
103
|
+
export async function deleteScheduleJob(
|
|
104
|
+
request: APIRequestContext,
|
|
105
|
+
token: string,
|
|
106
|
+
id: string | null,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
if (!id) return
|
|
109
|
+
await apiRequest(request, 'DELETE', SCHEDULER_JOBS_PATH, { token, data: { id } }).catch(() => null)
|
|
110
|
+
}
|