@tailor-platform/erp-kit 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -28
- package/dist/cli.js +894 -0
- package/package.json +65 -8
- package/rules/app-compose/backend/auth.md +78 -0
- package/rules/app-compose/frontend/auth.md +55 -0
- package/rules/app-compose/frontend/component.md +55 -0
- package/rules/app-compose/frontend/page.md +86 -0
- package/rules/app-compose/frontend/screen-detailview.md +112 -0
- package/rules/app-compose/frontend/screen-form.md +145 -0
- package/rules/app-compose/frontend/screen-listview.md +159 -0
- package/rules/app-compose/structure.md +32 -0
- package/rules/module-development/commands.md +54 -0
- package/rules/module-development/cross-module-type-injection.md +28 -0
- package/rules/module-development/dependency-modules.md +24 -0
- package/rules/module-development/errors.md +12 -0
- package/rules/module-development/executors.md +67 -0
- package/rules/module-development/exports.md +13 -0
- package/rules/module-development/models.md +34 -0
- package/rules/module-development/structure.md +27 -0
- package/rules/module-development/sync-vs-async-operations.md +83 -0
- package/rules/module-development/testing.md +43 -0
- package/rules/sdk-best-practices/db-relations.md +74 -0
- package/rules/sdk-best-practices/sdk-docs.md +14 -0
- package/schemas/app-compose/actors.yml +34 -0
- package/schemas/app-compose/business-flow.yml +50 -0
- package/schemas/app-compose/requirements.yml +33 -0
- package/schemas/app-compose/resolver.yml +47 -0
- package/schemas/app-compose/screen.yml +81 -0
- package/schemas/app-compose/story.yml +67 -0
- package/schemas/module/command.yml +52 -0
- package/schemas/module/feature.yml +58 -0
- package/schemas/module/model.yml +70 -0
- package/schemas/module/module.yml +50 -0
- package/skills/1-module-docs/SKILL.md +107 -0
- package/skills/2-module-feature-breakdown/SKILL.md +66 -0
- package/skills/3-module-doc-review/SKILL.md +230 -0
- package/skills/4-module-tdd-implementation/SKILL.md +56 -0
- package/skills/5-module-implementation-review/SKILL.md +400 -0
- package/skills/app-compose-1-requirement-analysis/SKILL.md +85 -0
- package/skills/app-compose-2-requirements-breakdown/SKILL.md +88 -0
- package/skills/app-compose-3-doc-review/SKILL.md +112 -0
- package/skills/app-compose-4-design-mock/SKILL.md +248 -0
- package/skills/app-compose-5-design-mock-review/SKILL.md +283 -0
- package/skills/app-compose-6-implementation-spec/SKILL.md +122 -0
- package/skills/mock-scenario/SKILL.md +118 -0
- package/src/app.ts +1 -0
- package/src/cli.ts +120 -0
- package/src/commands/check.test.ts +30 -0
- package/src/commands/check.ts +66 -0
- package/src/commands/init.test.ts +77 -0
- package/src/commands/init.ts +87 -0
- package/src/commands/mock/index.ts +53 -0
- package/src/commands/mock/start.ts +179 -0
- package/src/commands/mock/validate.test.ts +185 -0
- package/src/commands/mock/validate.ts +198 -0
- package/src/commands/scaffold.test.ts +76 -0
- package/src/commands/scaffold.ts +119 -0
- package/src/commands/sync-check.test.ts +125 -0
- package/src/commands/sync-check.ts +182 -0
- package/src/integration.test.ts +63 -0
- package/src/mdschema.ts +48 -0
- package/src/mockServer.ts +55 -0
- package/src/module.ts +86 -0
- package/src/modules/accounting/.gitkeep +0 -0
- package/src/modules/coa-management/.gitkeep +0 -0
- package/src/modules/inventory/.gitkeep +0 -0
- package/src/modules/manufacturing/.gitkeep +0 -0
- package/src/modules/primitives/README.md +39 -0
- package/src/modules/primitives/command/activateCategory.test.ts +75 -0
- package/src/modules/primitives/command/activateCategory.ts +50 -0
- package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
- package/src/modules/primitives/command/activateCurrency.ts +50 -0
- package/src/modules/primitives/command/activateUnit.test.ts +53 -0
- package/src/modules/primitives/command/activateUnit.ts +50 -0
- package/src/modules/primitives/command/convertAmount.test.ts +275 -0
- package/src/modules/primitives/command/convertAmount.ts +126 -0
- package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
- package/src/modules/primitives/command/convertQuantity.ts +73 -0
- package/src/modules/primitives/command/createCategory.test.ts +126 -0
- package/src/modules/primitives/command/createCategory.ts +89 -0
- package/src/modules/primitives/command/createCurrency.test.ts +191 -0
- package/src/modules/primitives/command/createCurrency.ts +77 -0
- package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
- package/src/modules/primitives/command/createExchangeRate.ts +91 -0
- package/src/modules/primitives/command/createUnit.test.ts +214 -0
- package/src/modules/primitives/command/createUnit.ts +88 -0
- package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
- package/src/modules/primitives/command/deactivateCategory.ts +62 -0
- package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
- package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
- package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
- package/src/modules/primitives/command/deactivateUnit.ts +62 -0
- package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
- package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
- package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
- package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
- package/src/modules/primitives/db/currency.ts +30 -0
- package/src/modules/primitives/db/exchangeRate.ts +28 -0
- package/src/modules/primitives/db/unit.ts +32 -0
- package/src/modules/primitives/db/uomCategory.ts +32 -0
- package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
- package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
- package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
- package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
- package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
- package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
- package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
- package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
- package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
- package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
- package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
- package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
- package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
- package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
- package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
- package/src/modules/primitives/docs/features/uom-categories.md +52 -0
- package/src/modules/primitives/docs/models/Currency.md +45 -0
- package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
- package/src/modules/primitives/docs/models/Unit.md +46 -0
- package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
- package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
- package/src/modules/primitives/index.ts +40 -0
- package/src/modules/primitives/lib/errors.ts +138 -0
- package/src/modules/primitives/lib/types.ts +20 -0
- package/src/modules/primitives/module.ts +66 -0
- package/src/modules/primitives/permissions.ts +18 -0
- package/src/modules/primitives/tailor.config.ts +11 -0
- package/src/modules/primitives/testing/fixtures.ts +161 -0
- package/src/modules/product-management/.gitkeep +0 -0
- package/src/modules/purchase/.gitkeep +0 -0
- package/src/modules/sales/.gitkeep +0 -0
- package/src/modules/shared/createContext.test.ts +39 -0
- package/src/modules/shared/createContext.ts +15 -0
- package/src/modules/shared/defineCommand.test.ts +42 -0
- package/src/modules/shared/defineCommand.ts +19 -0
- package/src/modules/shared/definePermissions.test.ts +146 -0
- package/src/modules/shared/definePermissions.ts +94 -0
- package/src/modules/shared/entityTypes.ts +15 -0
- package/src/modules/shared/errors.ts +22 -0
- package/src/modules/shared/index.ts +1 -0
- package/src/modules/shared/internal.ts +13 -0
- package/src/modules/shared/requirePermission.test.ts +47 -0
- package/src/modules/shared/requirePermission.ts +8 -0
- package/src/modules/shared/types.ts +4 -0
- package/src/modules/supplier-management/.gitkeep +0 -0
- package/src/modules/supplier-portal/.gitkeep +0 -0
- package/src/modules/testing/index.ts +120 -0
- package/src/modules/user-management/README.md +38 -0
- package/src/modules/user-management/command/activateUser.test.ts +112 -0
- package/src/modules/user-management/command/activateUser.ts +67 -0
- package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
- package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
- package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
- package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
- package/src/modules/user-management/command/createPermission.test.ts +143 -0
- package/src/modules/user-management/command/createPermission.ts +66 -0
- package/src/modules/user-management/command/createRole.test.ts +115 -0
- package/src/modules/user-management/command/createRole.ts +52 -0
- package/src/modules/user-management/command/createUser.test.ts +198 -0
- package/src/modules/user-management/command/createUser.ts +85 -0
- package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
- package/src/modules/user-management/command/deactivateUser.ts +67 -0
- package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
- package/src/modules/user-management/command/logAuditEvent.ts +59 -0
- package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
- package/src/modules/user-management/command/reactivateUser.ts +67 -0
- package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
- package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
- package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
- package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
- package/src/modules/user-management/db/auditEvent.ts +47 -0
- package/src/modules/user-management/db/permission.ts +31 -0
- package/src/modules/user-management/db/role.ts +28 -0
- package/src/modules/user-management/db/rolePermission.ts +44 -0
- package/src/modules/user-management/db/user.ts +38 -0
- package/src/modules/user-management/db/userRole.ts +44 -0
- package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
- package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
- package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
- package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
- package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
- package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
- package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
- package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
- package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
- package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
- package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
- package/src/modules/user-management/docs/features/audit-trail.md +80 -0
- package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
- package/src/modules/user-management/docs/features/user-account-management.md +64 -0
- package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
- package/src/modules/user-management/docs/models/Permission.md +31 -0
- package/src/modules/user-management/docs/models/Role.md +31 -0
- package/src/modules/user-management/docs/models/RolePermission.md +33 -0
- package/src/modules/user-management/docs/models/User.md +47 -0
- package/src/modules/user-management/docs/models/UserRole.md +34 -0
- package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
- package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
- package/src/modules/user-management/generated/enums.ts +24 -0
- package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
- package/src/modules/user-management/index.ts +32 -0
- package/src/modules/user-management/lib/errors.ts +81 -0
- package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
- package/src/modules/user-management/lib/types.ts +31 -0
- package/src/modules/user-management/module.ts +77 -0
- package/src/modules/user-management/permissions.ts +15 -0
- package/src/modules/user-management/tailor.config.ts +11 -0
- package/src/modules/user-management/testing/fixtures.ts +98 -0
- package/src/schemas.ts +25 -0
- package/src/testing.ts +10 -0
- package/src/util.ts +3 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
2
|
+
import { createServer as createNetServer } from "node:net";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { resolve, relative, join } from "node:path";
|
|
5
|
+
import { createMockServer, type MockServer } from "../../mockServer.js";
|
|
6
|
+
|
|
7
|
+
interface MockEntry {
|
|
8
|
+
provider: string;
|
|
9
|
+
scenario: string;
|
|
10
|
+
mockPath: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RunningMock {
|
|
14
|
+
server: MockServer;
|
|
15
|
+
route: string;
|
|
16
|
+
provider: string;
|
|
17
|
+
scenario: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readdirSafe(dir: string): string[] {
|
|
21
|
+
try {
|
|
22
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
23
|
+
.filter((d) => d.isDirectory())
|
|
24
|
+
.map((d) => d.name);
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function discoverMocks(mocksDir: string, filters: string[]): MockEntry[] {
|
|
31
|
+
const mocks: MockEntry[] = [];
|
|
32
|
+
|
|
33
|
+
for (const provider of readdirSafe(mocksDir)) {
|
|
34
|
+
const providerDir = join(mocksDir, provider);
|
|
35
|
+
for (const scenario of readdirSafe(providerDir)) {
|
|
36
|
+
const mockPath = join(providerDir, scenario, "mock.json");
|
|
37
|
+
if (!existsSync(mockPath)) continue;
|
|
38
|
+
mocks.push({ provider, scenario, mockPath });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (filters.length === 0) return mocks;
|
|
43
|
+
|
|
44
|
+
return mocks.filter(({ provider, scenario }) =>
|
|
45
|
+
filters.some((f) => f === provider || f === `${provider}/${scenario}`),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Mockoon doesn't expose the actual listening port, so we can't use port 0 with readback.
|
|
50
|
+
// This has a minor TOCTOU window between closing this server and Mockoon binding.
|
|
51
|
+
function findFreePort(): Promise<number> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const srv = createNetServer();
|
|
54
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
55
|
+
const addr = srv.address();
|
|
56
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
57
|
+
srv.close(() => resolve(port));
|
|
58
|
+
});
|
|
59
|
+
srv.on("error", reject);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function startMock(mock: MockEntry): Promise<RunningMock> {
|
|
64
|
+
const port = await findFreePort();
|
|
65
|
+
const server = await createMockServer(mock.mockPath, port);
|
|
66
|
+
const route = `/${mock.provider}/${mock.scenario}`;
|
|
67
|
+
return { server, route, provider: mock.provider, scenario: mock.scenario };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createProxy(routeTable: Map<string, RunningMock>) {
|
|
71
|
+
return createServer((req, res) => {
|
|
72
|
+
const match = req.url?.match(/^\/([^/?]+)\/([^/?]+)(\/[^?]*)?(\?.*)?$/);
|
|
73
|
+
if (!match) {
|
|
74
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
75
|
+
res.end(JSON.stringify({ error: "Not found. Use /{provider}/{scenario}/..." }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [, provider, scenario] = match;
|
|
80
|
+
const key = `/${provider}/${scenario}`;
|
|
81
|
+
const target = routeTable.get(key);
|
|
82
|
+
|
|
83
|
+
if (!target) {
|
|
84
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
85
|
+
res.end(
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
error: `Unknown route: ${key}`,
|
|
88
|
+
available: [...routeTable.keys()],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Strip the /{provider}/{scenario} prefix, keep the rest including query string
|
|
95
|
+
const downstream = (req.url ?? "/").slice(key.length) || "/";
|
|
96
|
+
|
|
97
|
+
const proxyReq = httpRequest(
|
|
98
|
+
{
|
|
99
|
+
hostname: "127.0.0.1",
|
|
100
|
+
port: target.server.port,
|
|
101
|
+
path: downstream,
|
|
102
|
+
method: req.method,
|
|
103
|
+
headers: { ...req.headers, host: `127.0.0.1:${target.server.port}` },
|
|
104
|
+
},
|
|
105
|
+
(proxyRes) => {
|
|
106
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
107
|
+
proxyRes.pipe(res);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
proxyReq.on("error", (err) => {
|
|
112
|
+
if (!res.headersSent) {
|
|
113
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
114
|
+
}
|
|
115
|
+
res.end(JSON.stringify({ error: "Bad gateway", detail: err.message }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
req.pipe(proxyReq);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function runMockStart(
|
|
123
|
+
mocksRoot: string,
|
|
124
|
+
filters: string[],
|
|
125
|
+
port: number,
|
|
126
|
+
): Promise<number> {
|
|
127
|
+
const mocksDir = resolve(mocksRoot);
|
|
128
|
+
const mocks = discoverMocks(mocksDir, filters);
|
|
129
|
+
|
|
130
|
+
if (mocks.length === 0) {
|
|
131
|
+
console.error("No matching mocks found.");
|
|
132
|
+
if (filters.length > 0) {
|
|
133
|
+
console.error(`Filters: ${filters.join(", ")}`);
|
|
134
|
+
console.error(`Available mocks are under: ${relative(process.cwd(), mocksDir)}/`);
|
|
135
|
+
}
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`Starting ${mocks.length} mock(s)...\n`);
|
|
140
|
+
|
|
141
|
+
const running: RunningMock[] = [];
|
|
142
|
+
for (const mock of mocks) {
|
|
143
|
+
const info = await startMock(mock);
|
|
144
|
+
running.push(info);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const routeTable = new Map<string, RunningMock>();
|
|
148
|
+
for (const r of running) {
|
|
149
|
+
routeTable.set(r.route, r);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log("Route table:");
|
|
153
|
+
console.log("\u2500".repeat(50));
|
|
154
|
+
for (const [route, { server }] of routeTable) {
|
|
155
|
+
console.log(` ${route} \u2192 127.0.0.1:${server.port}`);
|
|
156
|
+
}
|
|
157
|
+
console.log("\u2500".repeat(50));
|
|
158
|
+
|
|
159
|
+
const proxy = createProxy(routeTable);
|
|
160
|
+
proxy.listen(port, () => {
|
|
161
|
+
console.log(`\nReverse proxy listening on http://localhost:${port}`);
|
|
162
|
+
console.log("Press Ctrl+C to stop all mocks.\n");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function shutdown() {
|
|
166
|
+
console.log("\nShutting down...");
|
|
167
|
+
proxy.close();
|
|
168
|
+
for (const { server } of running) {
|
|
169
|
+
void server.stop();
|
|
170
|
+
}
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
process.on("SIGINT", shutdown);
|
|
175
|
+
process.on("SIGTERM", shutdown);
|
|
176
|
+
|
|
177
|
+
// Block until signal handler calls process.exit
|
|
178
|
+
return new Promise<number>(() => void 0);
|
|
179
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { runMockValidate } from "./validate.js";
|
|
6
|
+
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tempDir = await mkdtemp(join(tmpdir(), "mock-validate-"));
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
12
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function minimalMockJson() {
|
|
21
|
+
return {
|
|
22
|
+
uuid: "f7a1b2c3-d4e5-4f6a-8b7c-9d0e1f2a3b4c",
|
|
23
|
+
lastMigration: 32,
|
|
24
|
+
name: "Test API",
|
|
25
|
+
endpointPrefix: "",
|
|
26
|
+
port: 3000,
|
|
27
|
+
hostname: "0.0.0.0",
|
|
28
|
+
latency: 0,
|
|
29
|
+
folders: [],
|
|
30
|
+
rootChildren: [{ type: "route", uuid: "c5d8e2f1-3a4b-4c6d-9e7f-1a2b3c4d5e6f" }],
|
|
31
|
+
proxyMode: false,
|
|
32
|
+
proxyHost: "",
|
|
33
|
+
proxyRemovePrefix: false,
|
|
34
|
+
tlsOptions: {
|
|
35
|
+
enabled: false,
|
|
36
|
+
type: "CERT",
|
|
37
|
+
pfxPath: "",
|
|
38
|
+
certPath: "",
|
|
39
|
+
keyPath: "",
|
|
40
|
+
caPath: "",
|
|
41
|
+
passphrase: "",
|
|
42
|
+
},
|
|
43
|
+
cors: true,
|
|
44
|
+
headers: [{ key: "Content-Type", value: "application/json" }],
|
|
45
|
+
proxyReqHeaders: [],
|
|
46
|
+
proxyResHeaders: [],
|
|
47
|
+
callbacks: [],
|
|
48
|
+
data: [
|
|
49
|
+
{
|
|
50
|
+
uuid: "a3e72f41-8b56-4c9d-a1e7-2f4b8c56d9e3",
|
|
51
|
+
id: "items",
|
|
52
|
+
name: "Items",
|
|
53
|
+
documentation: "",
|
|
54
|
+
value: "[]",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
routes: [
|
|
58
|
+
{
|
|
59
|
+
uuid: "c5d8e2f1-3a4b-4c6d-9e7f-1a2b3c4d5e6f",
|
|
60
|
+
type: "crud",
|
|
61
|
+
documentation: "CRUD items",
|
|
62
|
+
method: "",
|
|
63
|
+
endpoint: "items",
|
|
64
|
+
responseMode: null,
|
|
65
|
+
streamingMode: null,
|
|
66
|
+
streamingInterval: 0,
|
|
67
|
+
responses: [
|
|
68
|
+
{
|
|
69
|
+
uuid: "d6e9f3a2-4b5c-4d7e-8f1a-2b3c4d5e6f7a",
|
|
70
|
+
label: "CRUD Items",
|
|
71
|
+
statusCode: 200,
|
|
72
|
+
latency: 0,
|
|
73
|
+
headers: [{ key: "Content-Type", value: "application/json" }],
|
|
74
|
+
body: "",
|
|
75
|
+
bodyType: "DATABUCKET",
|
|
76
|
+
databucketID: "items",
|
|
77
|
+
filePath: "",
|
|
78
|
+
sendFileAsBody: false,
|
|
79
|
+
rules: [],
|
|
80
|
+
rulesOperator: "OR",
|
|
81
|
+
disableTemplating: false,
|
|
82
|
+
fallbackTo404: false,
|
|
83
|
+
default: true,
|
|
84
|
+
crudKey: "id",
|
|
85
|
+
callbacks: [],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function createScenario(
|
|
94
|
+
provider: string,
|
|
95
|
+
scenario: string,
|
|
96
|
+
mockData?: object,
|
|
97
|
+
includeReadme = true,
|
|
98
|
+
) {
|
|
99
|
+
const scenarioDir = join(tempDir, provider, scenario);
|
|
100
|
+
await mkdir(scenarioDir, { recursive: true });
|
|
101
|
+
if (mockData) {
|
|
102
|
+
await writeFile(join(scenarioDir, "mock.json"), JSON.stringify(mockData));
|
|
103
|
+
}
|
|
104
|
+
if (includeReadme) {
|
|
105
|
+
await writeFile(join(scenarioDir, "README.md"), "# Test");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("runMockValidate", () => {
|
|
110
|
+
it("returns 0 for a valid scenario", async () => {
|
|
111
|
+
await createScenario("test-provider", "test-scenario", minimalMockJson());
|
|
112
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
113
|
+
expect(exitCode).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns 1 when no scenarios found", async () => {
|
|
117
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
118
|
+
expect(exitCode).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns 1 when mock.json is missing", async () => {
|
|
122
|
+
await createScenario("test-provider", "test-scenario", undefined, true);
|
|
123
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
124
|
+
expect(exitCode).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns 1 when README.md is missing", async () => {
|
|
128
|
+
await createScenario("test-provider", "test-scenario", minimalMockJson(), false);
|
|
129
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
130
|
+
expect(exitCode).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns 1 for invalid JSON", async () => {
|
|
134
|
+
const scenarioDir = join(tempDir, "test-provider", "test-scenario");
|
|
135
|
+
await mkdir(scenarioDir, { recursive: true });
|
|
136
|
+
await writeFile(join(scenarioDir, "mock.json"), "not json");
|
|
137
|
+
await writeFile(join(scenarioDir, "README.md"), "# Test");
|
|
138
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
139
|
+
expect(exitCode).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("detects missing Content-Type header", async () => {
|
|
143
|
+
const data = minimalMockJson();
|
|
144
|
+
data.routes[0].responses[0].headers = [];
|
|
145
|
+
await createScenario("test-provider", "test-scenario", data);
|
|
146
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
147
|
+
expect(exitCode).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("detects empty response label", async () => {
|
|
151
|
+
const data = minimalMockJson();
|
|
152
|
+
data.routes[0].responses[0].label = "";
|
|
153
|
+
await createScenario("test-provider", "test-scenario", data);
|
|
154
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
155
|
+
expect(exitCode).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("detects broken databucket reference", async () => {
|
|
159
|
+
const data = minimalMockJson();
|
|
160
|
+
data.routes[0].responses[0].databucketID = "nonexistent";
|
|
161
|
+
await createScenario("test-provider", "test-scenario", data);
|
|
162
|
+
const exitCode = await runMockValidate(tempDir, []);
|
|
163
|
+
expect(exitCode).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("validates specific paths when provided", async () => {
|
|
167
|
+
await createScenario("provider-a", "scenario-a", minimalMockJson());
|
|
168
|
+
await createScenario("provider-b", "scenario-b", minimalMockJson());
|
|
169
|
+
|
|
170
|
+
const exitCode = await runMockValidate(tempDir, [join(tempDir, "provider-a", "scenario-a")]);
|
|
171
|
+
expect(exitCode).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("can be called multiple times without state leaking", async () => {
|
|
175
|
+
// First call: failing scenario
|
|
176
|
+
await createScenario("bad", "scenario", undefined, true);
|
|
177
|
+
const first = await runMockValidate(tempDir, [join(tempDir, "bad", "scenario")]);
|
|
178
|
+
expect(first).toBe(1);
|
|
179
|
+
|
|
180
|
+
// Second call: valid scenario — failures counter must be reset
|
|
181
|
+
await createScenario("good", "scenario", minimalMockJson());
|
|
182
|
+
const second = await runMockValidate(tempDir, [join(tempDir, "good", "scenario")]);
|
|
183
|
+
expect(second).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, relative, dirname, basename } from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
interface ValidationContext {
|
|
6
|
+
failures: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function pass(msg: string) {
|
|
10
|
+
console.log(chalk.green(`\u2713 ${msg}`));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fail(ctx: ValidationContext, msg: string) {
|
|
14
|
+
console.log(chalk.red(`\u2717 ${msg}`));
|
|
15
|
+
ctx.failures++;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function subdirs(dir: string): Promise<string[]> {
|
|
19
|
+
const entries = await readdir(dir);
|
|
20
|
+
const dirs: string[] = [];
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const full = join(dir, entry);
|
|
23
|
+
const s = await stat(full);
|
|
24
|
+
if (s.isDirectory()) dirs.push(entry);
|
|
25
|
+
}
|
|
26
|
+
return dirs.sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkStructure(ctx: ValidationContext, entries: string[], label: string): boolean {
|
|
30
|
+
if (entries.includes("README.md")) {
|
|
31
|
+
pass(`${label}: has README.md`);
|
|
32
|
+
} else {
|
|
33
|
+
fail(ctx, `${label}: missing README.md`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (entries.includes("mock.json")) {
|
|
37
|
+
pass(`${label}: has mock.json`);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
fail(ctx, `${label}: missing mock.json`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function checkSchema(ctx: ValidationContext, data: MockoonData, label: string) {
|
|
45
|
+
const commons = await import("@mockoon/commons");
|
|
46
|
+
// EnvironmentSchemaNoFix is a Joi schema typed as `any` in @mockoon/commons
|
|
47
|
+
const schema = commons.EnvironmentSchemaNoFix as {
|
|
48
|
+
validate: (
|
|
49
|
+
value: unknown,
|
|
50
|
+
options?: { abortEarly?: boolean },
|
|
51
|
+
) => { error?: { details: { message: string }[] } };
|
|
52
|
+
};
|
|
53
|
+
const result = schema.validate(data, { abortEarly: false });
|
|
54
|
+
if (!result.error) {
|
|
55
|
+
pass(`${label}: valid Mockoon schema`);
|
|
56
|
+
} else {
|
|
57
|
+
for (const detail of result.error.details) {
|
|
58
|
+
fail(ctx, `${label}: schema — ${detail.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface MockoonRoute {
|
|
64
|
+
uuid: string;
|
|
65
|
+
responses?: MockoonResponse[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface MockoonResponse {
|
|
69
|
+
uuid: string;
|
|
70
|
+
label?: string;
|
|
71
|
+
headers?: { key: string; value: string }[];
|
|
72
|
+
bodyType?: string;
|
|
73
|
+
databucketID?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface MockoonData {
|
|
77
|
+
uuid?: string;
|
|
78
|
+
name?: string;
|
|
79
|
+
port?: number;
|
|
80
|
+
routes?: MockoonRoute[];
|
|
81
|
+
data?: { id: string }[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkResponseQuality(ctx: ValidationContext, data: MockoonData, label: string) {
|
|
85
|
+
const routes = data.routes ?? [];
|
|
86
|
+
for (const route of routes) {
|
|
87
|
+
for (const resp of route.responses ?? []) {
|
|
88
|
+
const respLabel = `${label} \u2192 ${route.uuid}/${resp.uuid}`;
|
|
89
|
+
const headers = resp.headers ?? [];
|
|
90
|
+
const hasContentType = headers.some((h) => h.key.toLowerCase() === "content-type");
|
|
91
|
+
if (hasContentType) {
|
|
92
|
+
pass(`${respLabel}: has Content-Type`);
|
|
93
|
+
} else {
|
|
94
|
+
fail(ctx, `${respLabel}: missing Content-Type header`);
|
|
95
|
+
}
|
|
96
|
+
if (resp.label && resp.label.trim().length > 0) {
|
|
97
|
+
pass(`${respLabel}: has label "${resp.label}"`);
|
|
98
|
+
} else {
|
|
99
|
+
fail(ctx, `${respLabel}: missing or empty label`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkDatabucketRefs(ctx: ValidationContext, data: MockoonData, label: string) {
|
|
106
|
+
const bucketIds = new Set((data.data ?? []).map((d) => d.id));
|
|
107
|
+
for (const route of data.routes ?? []) {
|
|
108
|
+
for (const resp of route.responses ?? []) {
|
|
109
|
+
if (resp.bodyType === "DATABUCKET") {
|
|
110
|
+
const respLabel = `${label} \u2192 ${route.uuid}/${resp.uuid}`;
|
|
111
|
+
if (resp.databucketID && bucketIds.has(resp.databucketID)) {
|
|
112
|
+
pass(`${respLabel}: databucket "${resp.databucketID}" exists`);
|
|
113
|
+
} else {
|
|
114
|
+
fail(ctx, `${respLabel}: databucketID "${resp.databucketID}" not found in data array`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function discoverAllScenarios(mocksDir: string): Promise<string[]> {
|
|
122
|
+
const scenarios: string[] = [];
|
|
123
|
+
const providers = await subdirs(mocksDir);
|
|
124
|
+
for (const provider of providers) {
|
|
125
|
+
const providerDir = join(mocksDir, provider);
|
|
126
|
+
for (const scenario of await subdirs(providerDir)) {
|
|
127
|
+
scenarios.push(`${provider}/${scenario}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return scenarios;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveScenarioDir(arg: string): string {
|
|
134
|
+
const abs = resolve(arg);
|
|
135
|
+
if (basename(abs) === "mock.json") return dirname(abs);
|
|
136
|
+
return abs;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function validateScenario(ctx: ValidationContext, scenarioDir: string, mocksDir: string) {
|
|
140
|
+
const label = relative(mocksDir, scenarioDir);
|
|
141
|
+
console.log(chalk.bold(`\n\u2500\u2500 ${label} \u2500\u2500`));
|
|
142
|
+
|
|
143
|
+
let entries: string[];
|
|
144
|
+
try {
|
|
145
|
+
entries = await readdir(scenarioDir);
|
|
146
|
+
} catch {
|
|
147
|
+
fail(ctx, `${label}: directory not found`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const hasMock = checkStructure(ctx, entries, label);
|
|
152
|
+
if (!hasMock) return;
|
|
153
|
+
|
|
154
|
+
const mockPath = join(scenarioDir, "mock.json");
|
|
155
|
+
|
|
156
|
+
let data: MockoonData;
|
|
157
|
+
try {
|
|
158
|
+
data = JSON.parse(await readFile(mockPath, "utf-8")) as MockoonData;
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
fail(ctx, `${label}: invalid JSON \u2014 ${errMsg}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await checkSchema(ctx, data, label);
|
|
166
|
+
checkResponseQuality(ctx, data, label);
|
|
167
|
+
checkDatabucketRefs(ctx, data, label);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function runMockValidate(mocksRoot: string, paths: string[]): Promise<number> {
|
|
171
|
+
const ctx: ValidationContext = { failures: 0 };
|
|
172
|
+
const mocksDir = resolve(mocksRoot);
|
|
173
|
+
|
|
174
|
+
const targets =
|
|
175
|
+
paths.length > 0
|
|
176
|
+
? paths.map(resolveScenarioDir)
|
|
177
|
+
: (await discoverAllScenarios(mocksDir)).map((s) => join(mocksDir, s));
|
|
178
|
+
|
|
179
|
+
if (targets.length === 0) {
|
|
180
|
+
fail(ctx, "No scenarios found under mocks/");
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(chalk.bold("\nValidating mock configs...\n"));
|
|
185
|
+
|
|
186
|
+
for (const target of targets) {
|
|
187
|
+
await validateScenario(ctx, target, mocksDir);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(chalk.bold("\n\u2500\u2500 summary \u2500\u2500"));
|
|
191
|
+
if (ctx.failures === 0) {
|
|
192
|
+
console.log(chalk.green("\u2713 All checks passed"));
|
|
193
|
+
} else {
|
|
194
|
+
console.log(chalk.red(`\u2717 ${ctx.failures} check(s) failed`));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return ctx.failures === 0 ? 0 : 1;
|
|
198
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveScaffoldPath } from "./scaffold.js";
|
|
3
|
+
|
|
4
|
+
describe("resolveScaffoldPath", () => {
|
|
5
|
+
// Module types
|
|
6
|
+
it("resolves module scaffold path", () => {
|
|
7
|
+
const result = resolveScaffoldPath("module", "inventory", undefined, "modules");
|
|
8
|
+
expect(result).toBe("modules/inventory/README.md");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("resolves command scaffold path", () => {
|
|
12
|
+
const result = resolveScaffoldPath("command", "inventory", "CreateOrder", "modules");
|
|
13
|
+
expect(result).toBe("modules/inventory/docs/commands/CreateOrder.md");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("resolves feature scaffold path", () => {
|
|
17
|
+
const result = resolveScaffoldPath("feature", "inventory", "stock-tracking", "modules");
|
|
18
|
+
expect(result).toBe("modules/inventory/docs/features/stock-tracking.md");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("resolves model scaffold path", () => {
|
|
22
|
+
const result = resolveScaffoldPath("model", "inventory", "StockItem", "modules");
|
|
23
|
+
expect(result).toBe("modules/inventory/docs/models/StockItem.md");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// App-compose types
|
|
27
|
+
it("resolves requirements scaffold path", () => {
|
|
28
|
+
const result = resolveScaffoldPath("requirements", "my-app", undefined, "examples");
|
|
29
|
+
expect(result).toBe("examples/my-app/README.md");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("resolves actors scaffold path", () => {
|
|
33
|
+
const result = resolveScaffoldPath("actors", "my-app", "admin", "examples");
|
|
34
|
+
expect(result).toBe("examples/my-app/docs/actors/admin.md");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("resolves business-flow scaffold path", () => {
|
|
38
|
+
const result = resolveScaffoldPath("business-flow", "my-app", "onboarding", "examples");
|
|
39
|
+
expect(result).toBe("examples/my-app/docs/business-flow/onboarding/README.md");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("resolves story scaffold path with flow/name format", () => {
|
|
43
|
+
const result = resolveScaffoldPath(
|
|
44
|
+
"story",
|
|
45
|
+
"my-app",
|
|
46
|
+
"onboarding/admin--create-user",
|
|
47
|
+
"examples",
|
|
48
|
+
);
|
|
49
|
+
expect(result).toBe(
|
|
50
|
+
"examples/my-app/docs/business-flow/onboarding/story/admin--create-user.md",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolves screen scaffold path", () => {
|
|
55
|
+
const result = resolveScaffoldPath("screen", "my-app", "supplier-list", "examples");
|
|
56
|
+
expect(result).toBe("examples/my-app/docs/screen/supplier-list.md");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("resolves resolver scaffold path", () => {
|
|
60
|
+
const result = resolveScaffoldPath("resolver", "my-app", "create-supplier", "examples");
|
|
61
|
+
expect(result).toBe("examples/my-app/docs/resolver/create-supplier.md");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Validation
|
|
65
|
+
it("throws when name is required but missing", () => {
|
|
66
|
+
expect(() => resolveScaffoldPath("command", "inventory", undefined, "modules")).toThrow(
|
|
67
|
+
"Name is required",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("throws when story name is not flow/name format", () => {
|
|
72
|
+
expect(() => resolveScaffoldPath("story", "my-app", "bad-name", "examples")).toThrow(
|
|
73
|
+
"Story name must be",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|