@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.
Files changed (23) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-002.spec.js +49 -0
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-002.spec.js.map +7 -0
  4. package/dist/modules/scheduler/__integration__/TC-SCHED-003.spec.js +38 -0
  5. package/dist/modules/scheduler/__integration__/TC-SCHED-003.spec.js.map +7 -0
  6. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +36 -0
  7. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +7 -0
  8. package/dist/modules/scheduler/__integration__/TC-SCHED-005.spec.js +41 -0
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-005.spec.js.map +7 -0
  10. package/dist/modules/scheduler/__integration__/TC-SCHED-006.spec.js +61 -0
  11. package/dist/modules/scheduler/__integration__/TC-SCHED-006.spec.js.map +7 -0
  12. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +36 -0
  13. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +7 -0
  14. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +59 -0
  15. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +7 -0
  16. package/package.json +5 -5
  17. package/src/modules/scheduler/__integration__/TC-SCHED-002.spec.ts +63 -0
  18. package/src/modules/scheduler/__integration__/TC-SCHED-003.spec.ts +48 -0
  19. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +52 -0
  20. package/src/modules/scheduler/__integration__/TC-SCHED-005.spec.ts +51 -0
  21. package/src/modules/scheduler/__integration__/TC-SCHED-006.spec.ts +84 -0
  22. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +50 -0
  23. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +110 -0
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 43 entry points
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.4588.1.ecaa16cfc0",
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.4588.1.ecaa16cfc0",
62
- "@open-mercato/queue": "0.6.5-develop.4588.1.ecaa16cfc0",
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.4588.1.ecaa16cfc0",
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.4588.1.ecaa16cfc0",
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
+ }