create-fullstack-scaffold 0.4.9 → 0.4.10

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 (127) hide show
  1. package/dist/cli/index.js +499 -100
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/template/CLAUDE.md +18 -6
  5. package/template/drizzle/0004_add_merchants_products.sql +30 -0
  6. package/template/drizzle/meta/_journal.json +7 -0
  7. package/template/eslint-rules/module-boundary.js +6 -2
  8. package/template/eslint-rules/no-cross-module-service-import.js +105 -0
  9. package/template/eslint-rules/no-disable-type-safe-client.js +5 -0
  10. package/template/eslint.config.js +9 -0
  11. package/template/lint-scripts/config/project.config.ts +21 -0
  12. package/template/lint-scripts/validate-all.ts +48 -12
  13. package/template/lint-scripts/validators/index.ts +29 -0
  14. package/template/lint-scripts/validators/module-public-api.validator.ts +103 -0
  15. package/template/lint-scripts/validators/schema-uniqueness.validator.ts +147 -0
  16. package/template/modules.config.ts +2 -0
  17. package/template/package.json +2 -0
  18. package/template/playwright.config.ts +14 -0
  19. package/template/src/admin/App.tsx +40 -3
  20. package/template/src/admin/components/LanguageSwitcher.tsx +22 -0
  21. package/template/src/admin/components/NotificationDrawer.tsx +108 -38
  22. package/template/src/admin/components/StatsCard.tsx +3 -1
  23. package/template/src/admin/components/ThemeToggle.tsx +28 -0
  24. package/template/src/admin/hooks/useRoles.ts +2 -2
  25. package/template/src/admin/i18n/index.ts +18 -0
  26. package/template/src/admin/i18n/locales/en-US.json +381 -0
  27. package/template/src/admin/i18n/locales/zh-CN.json +381 -0
  28. package/template/src/admin/i18n/useLanguage.ts +21 -0
  29. package/template/src/admin/layouts/Header.tsx +37 -16
  30. package/template/src/admin/layouts/Layout.tsx +7 -3
  31. package/template/src/admin/layouts/Sidebar.tsx +70 -68
  32. package/template/src/admin/main.tsx +1 -0
  33. package/template/src/admin/pages/ContentPage.tsx +68 -56
  34. package/template/src/admin/pages/DashboardPage.tsx +82 -76
  35. package/template/src/admin/pages/DisputesPage.tsx +61 -53
  36. package/template/src/admin/pages/LoginPage.tsx +41 -33
  37. package/template/src/admin/pages/OrdersPage.tsx +69 -64
  38. package/template/src/admin/pages/PermissionsPage.tsx +10 -8
  39. package/template/src/admin/pages/PluginDashboardPage.tsx +3 -1
  40. package/template/src/admin/pages/PluginManagementPage.tsx +3 -1
  41. package/template/src/admin/pages/RegisterPage.tsx +18 -16
  42. package/template/src/admin/pages/RolesPage.tsx +51 -49
  43. package/template/src/admin/pages/SettingsPage.tsx +22 -22
  44. package/template/src/admin/pages/SystemLogsPage.tsx +32 -30
  45. package/template/src/admin/pages/TicketsPage.tsx +67 -59
  46. package/template/src/admin/pages/UsersPage.tsx +63 -53
  47. package/template/src/admin/pages/__tests__/RolesPage.test.tsx +2 -2
  48. package/template/src/admin/stores/themeStore.ts +32 -0
  49. package/template/src/client/index.css +73 -0
  50. package/template/src/merchant/App.tsx +2 -0
  51. package/template/src/merchant/components/MerchantGuard.tsx +2 -6
  52. package/template/src/merchant/components/__tests__/MerchantGuard.test.tsx +117 -0
  53. package/template/src/merchant/layouts/Header.tsx +2 -2
  54. package/template/src/merchant/pages/DashboardPage.tsx +2 -2
  55. package/template/src/merchant/pages/DisputesPage.tsx +14 -10
  56. package/template/src/merchant/pages/LoginPage.tsx +49 -0
  57. package/template/src/merchant/pages/OrdersPage.tsx +17 -10
  58. package/template/src/merchant/pages/ProductsPage.tsx +31 -8
  59. package/template/src/merchant/pages/SettingsPage.tsx +3 -7
  60. package/template/src/merchant/stores/__tests__/merchantStore.test.ts +317 -0
  61. package/template/src/merchant/stores/merchantStore.ts +24 -27
  62. package/template/src/server/db/schema/index.ts +2 -0
  63. package/template/src/server/db/schema/merchants.ts +26 -0
  64. package/template/src/server/db/schema/products.ts +25 -0
  65. package/template/src/server/db/test-setup.ts +237 -0
  66. package/template/src/server/middleware/__tests__/audit-log.test.ts +144 -0
  67. package/template/src/server/middleware/__tests__/cors.test.ts +84 -0
  68. package/template/src/server/middleware/__tests__/logger.test.ts +117 -0
  69. package/template/src/server/middleware/__tests__/rate-limit.test.ts +73 -0
  70. package/template/src/server/middleware/__tests__/realtime-env.test.ts +56 -0
  71. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +150 -0
  72. package/template/src/server/module-admin/__tests__/admin-routes.test.ts +1 -1
  73. package/template/src/server/module-admin/module.ts +4 -0
  74. package/template/src/server/module-admin/routes/admin-notification-routes.ts +3 -3
  75. package/template/src/server/module-admin/routes/user-management-routes.ts +2 -2
  76. package/template/src/server/module-auth/__tests__/auth-routes.test.ts +315 -0
  77. package/template/src/server/module-auth/__tests__/profile-routes.test.ts +83 -0
  78. package/template/src/server/module-auth/module.ts +2 -0
  79. package/template/src/server/module-content/module.ts +1 -0
  80. package/template/src/server/module-content/routes/content-routes.ts +2 -2
  81. package/template/src/server/module-dispute/module.ts +1 -0
  82. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -2
  83. package/template/src/server/module-merchant/__tests__/merchant-routes.test.ts +218 -0
  84. package/template/src/server/module-merchant/__tests__/merchant-service.test.ts +183 -0
  85. package/template/src/server/module-merchant/index.ts +10 -0
  86. package/template/src/server/module-merchant/module.ts +33 -0
  87. package/template/src/server/module-merchant/routes/merchant-routes.ts +138 -0
  88. package/template/src/server/module-merchant/services/merchant-service.ts +292 -0
  89. package/template/src/server/module-notifications/index.ts +18 -0
  90. package/template/src/server/module-notifications/module.ts +2 -0
  91. package/template/src/server/module-order/module.ts +1 -0
  92. package/template/src/server/module-order/routes/order-routes.ts +2 -2
  93. package/template/src/server/module-permission/routes/role-routes.ts +3 -3
  94. package/template/src/server/module-plugin/__tests__/admin-category-service.test.ts +181 -0
  95. package/template/src/server/module-plugin/__tests__/admin-plugin-service.test.ts +277 -0
  96. package/template/src/server/module-plugin/__tests__/admin-stats-service.test.ts +170 -0
  97. package/template/src/server/module-plugin/__tests__/plugin-review-service.test.ts +260 -0
  98. package/template/src/server/module-plugin/__tests__/plugin-seed-service.test.ts +170 -0
  99. package/template/src/server/module-plugin/module.ts +3 -0
  100. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +142 -0
  101. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +152 -0
  102. package/template/src/server/module-tenant/module.ts +1 -0
  103. package/template/src/server/module-tenant/services/tenant-service.ts +1 -37
  104. package/template/src/server/module-ticket/module.ts +1 -0
  105. package/template/src/server/module-ticket/routes/ticket-routes.ts +2 -2
  106. package/template/src/server/module-todos/module.ts +3 -0
  107. package/template/src/server/route-registry.ts +4 -0
  108. package/template/src/server/utils/__tests__/date.test.ts +130 -0
  109. package/template/src/server/utils/__tests__/env.test.ts +25 -0
  110. package/template/src/server/utils/__tests__/generate.test.ts +82 -0
  111. package/template/src/server/utils/__tests__/id-helpers.test.ts +29 -0
  112. package/template/src/server/utils/__tests__/json.test.ts +49 -0
  113. package/template/src/server/utils/__tests__/permission-utils.test.ts +26 -0
  114. package/template/src/server/utils/__tests__/uuid.test.ts +25 -0
  115. package/template/src/shared/core/module-manifest.ts +15 -0
  116. package/template/src/shared/modules/admin/schemas.ts +1 -1
  117. package/template/src/shared/modules/content/schemas.ts +2 -2
  118. package/template/src/shared/modules/dispute/schemas.ts +2 -2
  119. package/template/src/shared/modules/index.ts +181 -1
  120. package/template/src/shared/modules/merchant/index.ts +12 -0
  121. package/template/src/shared/modules/merchant/schemas.ts +63 -0
  122. package/template/src/shared/modules/order/schemas.ts +2 -2
  123. package/template/src/shared/modules/permission/index.ts +1 -1
  124. package/template/src/shared/modules/permission/schemas.ts +1 -1
  125. package/template/src/shared/modules/role/schemas.ts +2 -2
  126. package/template/src/shared/modules/ticket/schemas.ts +2 -2
  127. package/template/src/shared/schemas/index.ts +74 -2
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
2
+ import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
3
+ import {
4
+ listTenants,
5
+ getTenantById,
6
+ getTenantBySlug,
7
+ createTenant,
8
+ updateTenant,
9
+ deleteTenant,
10
+ } from '../services/tenant-service'
11
+ import type { CreateTenantInput } from '@shared/schemas'
12
+
13
+ describe('Tenant Service', () => {
14
+ beforeAll(async () => {
15
+ await setupTestDatabase()
16
+ })
17
+
18
+ afterAll(async () => {
19
+ await cleanupTestDatabase()
20
+ })
21
+
22
+ describe('listTenants', () => {
23
+ it('should return paginated list', async () => {
24
+ const result = await listTenants(1, 20)
25
+ expect(result).toHaveProperty('items')
26
+ expect(result).toHaveProperty('total')
27
+ expect(result).toHaveProperty('page', 1)
28
+ expect(result).toHaveProperty('pageSize', 20)
29
+ expect(Array.isArray(result.items)).toBe(true)
30
+ })
31
+
32
+ it('should filter by status', async () => {
33
+ const result = await listTenants(1, 20, { status: 'active' })
34
+ for (const item of result.items) {
35
+ expect(item.status).toBe('active')
36
+ }
37
+ })
38
+
39
+ it('should filter by plan', async () => {
40
+ const result = await listTenants(1, 20, { plan: 'pro' })
41
+ for (const item of result.items) {
42
+ expect(item.plan).toBe('pro')
43
+ }
44
+ })
45
+ })
46
+
47
+ describe('getTenantById', () => {
48
+ it('should return null for non-existent id', async () => {
49
+ const result = await getTenantById(99999)
50
+ expect(result).toBeNull()
51
+ })
52
+
53
+ it('should return tenant for valid id', async () => {
54
+ const list = await listTenants(1, 1)
55
+ if (list.items.length > 0) {
56
+ const tenant = await getTenantById(list.items[0].id)
57
+ expect(tenant).not.toBeNull()
58
+ expect(tenant!.id).toBe(list.items[0].id)
59
+ expect(tenant).toHaveProperty('name')
60
+ expect(tenant).toHaveProperty('slug')
61
+ }
62
+ })
63
+ })
64
+
65
+ describe('getTenantBySlug', () => {
66
+ it('should return null for non-existent slug', async () => {
67
+ const result = await getTenantBySlug('non-existent-slug')
68
+ expect(result).toBeNull()
69
+ })
70
+
71
+ it('should return tenant by slug', async () => {
72
+ const result = await getTenantBySlug('demo')
73
+ expect(result).not.toBeNull()
74
+ expect(result!.slug).toBe('demo')
75
+ })
76
+ })
77
+
78
+ describe('createTenant', () => {
79
+ it('should create a tenant with defaults', async () => {
80
+ const input: CreateTenantInput = {
81
+ name: 'New Corp',
82
+ slug: 'new-corp',
83
+ plan: 'free',
84
+ maxUsers: 5,
85
+ settings: null,
86
+ }
87
+ const result = await createTenant(input)
88
+ expect(result.name).toBe('New Corp')
89
+ expect(result.slug).toBe('new-corp')
90
+ expect(result.status).toBe('trial')
91
+ expect(result.plan).toBe('free')
92
+ })
93
+
94
+ it('should reject duplicate slug', async () => {
95
+ await expect(
96
+ createTenant({
97
+ name: 'Duplicate',
98
+ slug: 'demo',
99
+ plan: 'free',
100
+ maxUsers: 5,
101
+ settings: null,
102
+ })
103
+ ).rejects.toThrow(/already exists/)
104
+ })
105
+ })
106
+
107
+ describe('updateTenant', () => {
108
+ it('should return null for non-existent id', async () => {
109
+ const result = await updateTenant(99999, { name: 'Updated' })
110
+ expect(result).toBeNull()
111
+ })
112
+
113
+ it('should update tenant fields', async () => {
114
+ // Create a tenant to update
115
+ const created = await createTenant({
116
+ name: 'Update Test',
117
+ slug: 'update-test',
118
+ plan: 'free',
119
+ maxUsers: 5,
120
+ settings: null,
121
+ })
122
+
123
+ const result = await updateTenant(created.id, { name: 'Updated Name' })
124
+ expect(result).not.toBeNull()
125
+ expect(result!.name).toBe('Updated Name')
126
+ })
127
+ })
128
+
129
+ describe('deleteTenant', () => {
130
+ it('should return false for non-existent id', async () => {
131
+ const result = await deleteTenant(99999)
132
+ expect(result).toBe(false)
133
+ })
134
+
135
+ it('should delete and return true', async () => {
136
+ const created = await createTenant({
137
+ name: 'Delete Test',
138
+ slug: 'delete-test',
139
+ plan: 'free',
140
+ maxUsers: 5,
141
+ settings: null,
142
+ })
143
+
144
+ const result = await deleteTenant(created.id)
145
+ expect(result).toBe(true)
146
+
147
+ // Verify deleted
148
+ const found = await getTenantById(created.id)
149
+ expect(found).toBeNull()
150
+ })
151
+ })
152
+ })
@@ -24,6 +24,7 @@ const tenantManifest: ModuleManifest = {
24
24
  dbSchemas: {
25
25
  files: ['tenants'],
26
26
  hasSeed: true,
27
+ seed: { serviceFile: 'tenant-service', functionName: 'seedTenantsIfEmpty' },
27
28
  },
28
29
  }
29
30
 
@@ -203,7 +203,7 @@ export async function updateTenant(id: number, input: UpdateTenantInput): Promis
203
203
  }
204
204
 
205
205
  if (input.name !== undefined) {
206
- updateData.name = input.name
206
+ updateData.name = input.name ?? undefined
207
207
  }
208
208
 
209
209
  if (input.status !== undefined && input.status !== null) {
@@ -222,42 +222,6 @@ export async function updateTenant(id: number, input: UpdateTenantInput): Promis
222
222
  updateData.settings = input.settings ? JSON.stringify(input.settings) : null
223
223
  }
224
224
 
225
- if (input.name !== undefined) {
226
- updateData.name = input.name
227
- }
228
-
229
- if (input.status !== undefined && input.status !== null) {
230
- updateData.status = input.status
231
- }
232
-
233
- if (input.plan !== undefined && input.plan !== null) {
234
- updateData.plan = input.plan
235
- }
236
-
237
- if (input.maxUsers !== undefined && input.maxUsers !== null) {
238
- updateData.maxUsers = input.maxUsers
239
- }
240
-
241
- if (input.name !== undefined) {
242
- updateData.name = input.name
243
- }
244
-
245
- if (input.status !== undefined && input.status !== null) {
246
- ;(updateData as Record<string, unknown>).status = input.status
247
- }
248
-
249
- if (input.plan !== undefined && input.plan !== null) {
250
- ;(updateData as Record<string, unknown>).plan = input.plan
251
- }
252
-
253
- if (input.maxUsers !== undefined && input.maxUsers !== null) {
254
- ;(updateData as Record<string, unknown>).maxUsers = input.maxUsers
255
- }
256
-
257
- if (input.settings !== undefined) {
258
- updateData.settings = input.settings ? JSON.stringify(input.settings) : null
259
- }
260
-
261
225
  const result = await db.update(tenants).set(updateData).where(eq(tenants.id, id)).returning()
262
226
 
263
227
  if (result.length === 0) return null
@@ -24,6 +24,7 @@ const ticketManifest: ModuleManifest = {
24
24
  dbSchemas: {
25
25
  files: ['tickets'],
26
26
  hasSeed: true,
27
+ seed: { serviceFile: 'ticket-service', functionName: 'seedTicketsIfEmpty' },
27
28
  },
28
29
  }
29
30
 
@@ -10,7 +10,7 @@ import {
10
10
  CreateTicketSchema,
11
11
  UpdateTicketSchema,
12
12
  TicketListSchema,
13
- DeleteResultSchema,
13
+ TicketDeleteResultSchema,
14
14
  ReplyTicketSchema,
15
15
  } from '@shared/modules/ticket'
16
16
 
@@ -112,7 +112,7 @@ const deleteRoute = createRoute({
112
112
  params: TicketSchema.pick({ id: true }),
113
113
  },
114
114
  responses: {
115
- 200: successResponse(DeleteResultSchema, 'Ticket deleted'),
115
+ 200: successResponse(TicketDeleteResultSchema, 'Ticket deleted'),
116
116
  401: errorResponse('Unauthorized'),
117
117
  403: errorResponse('Forbidden'),
118
118
  404: errorResponse('Ticket not found'),
@@ -24,7 +24,10 @@ const todosManifest: ModuleManifest = {
24
24
  dbSchemas: {
25
25
  files: ['todos', 'todo-attachments'],
26
26
  hasSeed: true,
27
+ seed: { serviceFile: 'todo-service', functionName: 'seedTodosIfEmpty' },
27
28
  },
29
+
30
+ cliModule: { dir: 'todo', registerFunction: 'registerTodoCommands' },
28
31
  }
29
32
 
30
33
  export default todosManifest
@@ -22,6 +22,8 @@ import { dashboardRoutes } from './module-admin/routes/dashboard-routes'
22
22
  import { cartRoutes } from './module-order/routes/cart-routes'
23
23
  import { ordersMockRoutes } from './module-order/routes/orders-mock-routes'
24
24
  import { topicsRoutes } from './module-content/routes/topics-routes'
25
+ import { apiRoutes as tenantRoutes } from './module-tenant/routes/tenant-routes'
26
+ import { apiRoutes as merchantRoutes } from './module-merchant/routes/merchant-routes'
25
27
 
26
28
  const apiRateLimit = rateLimitMiddleware({
27
29
  windowMs: 60_000,
@@ -41,6 +43,7 @@ export const clientApiRoutes = new OpenAPIHono()
41
43
  .route('/api', cartRoutes)
42
44
  .route('/api', ordersMockRoutes)
43
45
  .route('/api', topicsRoutes)
46
+ .route('/api', merchantRoutes)
44
47
 
45
48
  // 管理后台路由 - 普通用户使用的 API + 管理功能
46
49
  export const adminApiRoutes = new OpenAPIHono()
@@ -56,6 +59,7 @@ export const adminApiRoutes = new OpenAPIHono()
56
59
  .route('/api', adminRoutes)
57
60
  .route('/api', pluginAdminRoutes)
58
61
  .route('/api', dashboardRoutes)
62
+ .route('/api', tenantRoutes)
59
63
 
60
64
  // 导出类型
61
65
  export type ClientApiRoutes = typeof clientApiRoutes
@@ -0,0 +1,130 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import {
4
+ toISOString,
5
+ formatDate,
6
+ parseDate,
7
+ getTimestamp,
8
+ transformDateField,
9
+ transformRole,
10
+ transformAuditLog,
11
+ } from '../date'
12
+
13
+ describe('date utils', () => {
14
+ describe('toISOString', () => {
15
+ it('should convert Date to ISO string', () => {
16
+ const date = new Date('2024-01-15T10:30:00.000Z')
17
+ expect(toISOString(date)).toBe('2024-01-15T10:30:00.000Z')
18
+ })
19
+ })
20
+
21
+ describe('formatDate', () => {
22
+ it('should format Date object', () => {
23
+ const date = new Date('2024-01-15T10:30:00.000Z')
24
+ expect(formatDate(date)).toBe('2024-01-15T10:30:00.000Z')
25
+ })
26
+
27
+ it('should format date string', () => {
28
+ const result = formatDate('2024-01-15')
29
+ expect(typeof result).toBe('string')
30
+ expect(new Date(result).getFullYear()).toBe(2024)
31
+ })
32
+
33
+ it('should format timestamp number', () => {
34
+ const ts = new Date('2024-01-15T10:30:00.000Z').getTime()
35
+ expect(formatDate(ts)).toBe('2024-01-15T10:30:00.000Z')
36
+ })
37
+ })
38
+
39
+ describe('parseDate', () => {
40
+ it('should parse date string to Date', () => {
41
+ const result = parseDate('2024-01-15T10:30:00.000Z')
42
+ expect(result).toBeInstanceOf(Date)
43
+ expect(result.toISOString()).toBe('2024-01-15T10:30:00.000Z')
44
+ })
45
+ })
46
+
47
+ describe('getTimestamp', () => {
48
+ it('should return current timestamp in milliseconds', () => {
49
+ const before = Date.now()
50
+ const result = getTimestamp()
51
+ const after = Date.now()
52
+ expect(result).toBeGreaterThanOrEqual(before)
53
+ expect(result).toBeLessThanOrEqual(after)
54
+ expect(typeof result).toBe('number')
55
+ })
56
+ })
57
+
58
+ describe('transformDateField', () => {
59
+ it('should convert Date to ISO string', () => {
60
+ const date = new Date('2024-01-15T00:00:00.000Z')
61
+ expect(transformDateField(date)).toBe('2024-01-15T00:00:00.000Z')
62
+ })
63
+
64
+ it('should return current time for null', () => {
65
+ const before = new Date().toISOString()
66
+ const result = transformDateField(null)
67
+ const after = new Date().toISOString()
68
+ expect(result >= before).toBe(true)
69
+ expect(result <= after).toBe(true)
70
+ })
71
+
72
+ it('should return current time for undefined', () => {
73
+ const before = new Date().toISOString()
74
+ const result = transformDateField(undefined)
75
+ const after = new Date().toISOString()
76
+ expect(result >= before).toBe(true)
77
+ expect(result <= after).toBe(true)
78
+ })
79
+ })
80
+
81
+ describe('transformRole', () => {
82
+ it('should transform role dates to strings', () => {
83
+ const role = {
84
+ id: 'role-1',
85
+ name: 'admin',
86
+ createdAt: new Date('2024-01-01T00:00:00.000Z'),
87
+ updatedAt: new Date('2024-06-01T00:00:00.000Z'),
88
+ }
89
+ const result = transformRole(role)
90
+ expect(result.id).toBe('role-1')
91
+ expect(result.name).toBe('admin')
92
+ expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z')
93
+ expect(result.updatedAt).toBe('2024-06-01T00:00:00.000Z')
94
+ })
95
+
96
+ it('should handle null dates', () => {
97
+ const role = {
98
+ id: 'role-2',
99
+ name: 'user',
100
+ createdAt: null,
101
+ updatedAt: null,
102
+ }
103
+ const result = transformRole(role)
104
+ expect(result.id).toBe('role-2')
105
+ expect(typeof result.createdAt).toBe('string')
106
+ expect(typeof result.updatedAt).toBe('string')
107
+ })
108
+ })
109
+
110
+ describe('transformAuditLog', () => {
111
+ it('should transform audit log date to string', () => {
112
+ const log = {
113
+ id: 'log-1',
114
+ action: 'CREATE',
115
+ createdAt: new Date('2024-01-01T00:00:00.000Z'),
116
+ }
117
+ const result = transformAuditLog(log)
118
+ expect(result.id).toBe('log-1')
119
+ expect(result.action).toBe('CREATE')
120
+ expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z')
121
+ })
122
+
123
+ it('should handle null createdAt', () => {
124
+ const log = { id: 'log-2', createdAt: null }
125
+ const result = transformAuditLog(log)
126
+ expect(result.id).toBe('log-2')
127
+ expect(typeof result.createdAt).toBe('string')
128
+ })
129
+ })
130
+ })
@@ -0,0 +1,25 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { isCloudflare, isNode } from '../env'
4
+
5
+ describe('env', () => {
6
+ it('should export isCloudflare as boolean', () => {
7
+ expect(typeof isCloudflare).toBe('boolean')
8
+ })
9
+
10
+ it('should export isNode as boolean', () => {
11
+ expect(typeof isNode).toBe('boolean')
12
+ })
13
+
14
+ it('should detect Node environment', () => {
15
+ expect(isNode).toBe(true)
16
+ })
17
+
18
+ it('isCloudflare should be false in Node environment', () => {
19
+ expect(isCloudflare).toBe(false)
20
+ })
21
+
22
+ it('isCloudflare and isNode should be mutually exclusive', () => {
23
+ expect(isCloudflare && isNode).toBe(false)
24
+ })
25
+ })
@@ -0,0 +1,82 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import {
4
+ generateOrderNo,
5
+ generateTicketNo,
6
+ generateDisputeNo,
7
+ randomDate,
8
+ randomElement,
9
+ } from '../generate'
10
+
11
+ describe('generate', () => {
12
+ describe('generateOrderNo', () => {
13
+ it('should start with ORD prefix', () => {
14
+ expect(generateOrderNo()).toMatch(/^ORD/)
15
+ })
16
+
17
+ it('should generate unique values', () => {
18
+ expect(generateOrderNo()).not.toBe(generateOrderNo())
19
+ })
20
+
21
+ it('should have length greater than 10', () => {
22
+ expect(generateOrderNo().length).toBeGreaterThan(10)
23
+ })
24
+ })
25
+
26
+ describe('generateTicketNo', () => {
27
+ it('should start with TKT prefix', () => {
28
+ expect(generateTicketNo()).toMatch(/^TKT/)
29
+ })
30
+
31
+ it('should generate unique values', () => {
32
+ expect(generateTicketNo()).not.toBe(generateTicketNo())
33
+ })
34
+ })
35
+
36
+ describe('generateDisputeNo', () => {
37
+ it('should start with DSP prefix', () => {
38
+ expect(generateDisputeNo()).toMatch(/^DSP/)
39
+ })
40
+
41
+ it('should generate unique values', () => {
42
+ expect(generateDisputeNo()).not.toBe(generateDisputeNo())
43
+ })
44
+ })
45
+
46
+ describe('randomDate', () => {
47
+ it('should return ISO string between start and end', () => {
48
+ const start = new Date('2024-01-01T00:00:00.000Z')
49
+ const end = new Date('2024-12-31T23:59:59.999Z')
50
+ const result = randomDate(start, end)
51
+ const resultDate = new Date(result)
52
+ expect(resultDate.getTime()).toBeGreaterThanOrEqual(start.getTime())
53
+ expect(resultDate.getTime()).toBeLessThanOrEqual(end.getTime())
54
+ })
55
+
56
+ it('should return valid ISO string', () => {
57
+ const start = new Date('2020-01-01')
58
+ const end = new Date('2025-01-01')
59
+ const result = randomDate(start, end)
60
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T/)
61
+ })
62
+ })
63
+
64
+ describe('randomElement', () => {
65
+ it('should return element from array', () => {
66
+ const arr = [1, 2, 3, 4, 5]
67
+ const result = randomElement(arr)
68
+ expect(arr).toContain(result)
69
+ })
70
+
71
+ it('should handle single-element array', () => {
72
+ expect(randomElement([42])).toBe(42)
73
+ })
74
+
75
+ it('should return string from string array', () => {
76
+ const arr = ['a', 'b', 'c']
77
+ const result = randomElement(arr)
78
+ expect(typeof result).toBe('string')
79
+ expect(arr).toContain(result)
80
+ })
81
+ })
82
+ })
@@ -0,0 +1,29 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { parseModuleId } from '../id-helpers'
4
+
5
+ describe('parseModuleId', () => {
6
+ it('should parse numeric id with prefix', () => {
7
+ expect(parseModuleId('order', 'order-123')).toBe(123)
8
+ })
9
+
10
+ it('should parse id with single digit', () => {
11
+ expect(parseModuleId('todo', 'todo-5')).toBe(5)
12
+ })
13
+
14
+ it('should return -1 for non-numeric id', () => {
15
+ expect(parseModuleId('order', 'order-abc')).toBe(-1)
16
+ })
17
+
18
+ it('should return -1 for missing prefix', () => {
19
+ expect(parseModuleId('order', 'ticket-123')).toBe(-1)
20
+ })
21
+
22
+ it('should handle zero id', () => {
23
+ expect(parseModuleId('test', 'test-0')).toBe(0)
24
+ })
25
+
26
+ it('should parse large numeric id', () => {
27
+ expect(parseModuleId('item', 'item-999999')).toBe(999999)
28
+ })
29
+ })
@@ -0,0 +1,49 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { parseJsonField, serializeJsonField } from '../json'
4
+
5
+ describe('json utils', () => {
6
+ describe('parseJsonField', () => {
7
+ it('should parse valid JSON string', () => {
8
+ expect(parseJsonField<{ name: string }>('{"name":"test"}')).toEqual({ name: 'test' })
9
+ })
10
+
11
+ it('should return undefined for null', () => {
12
+ expect(parseJsonField(null)).toBeUndefined()
13
+ })
14
+
15
+ it('should return undefined for undefined', () => {
16
+ expect(parseJsonField(undefined)).toBeUndefined()
17
+ })
18
+
19
+ it('should return undefined for empty string', () => {
20
+ expect(parseJsonField('')).toBeUndefined()
21
+ })
22
+
23
+ it('should return undefined for invalid JSON', () => {
24
+ expect(parseJsonField('{invalid}')).toBeUndefined()
25
+ })
26
+
27
+ it('should parse array JSON', () => {
28
+ expect(parseJsonField<number[]>('[1,2,3]')).toEqual([1, 2, 3])
29
+ })
30
+ })
31
+
32
+ describe('serializeJsonField', () => {
33
+ it('should serialize object to JSON string', () => {
34
+ expect(serializeJsonField({ name: 'test' })).toBe('{"name":"test"}')
35
+ })
36
+
37
+ it('should return null for undefined', () => {
38
+ expect(serializeJsonField(undefined)).toBeNull()
39
+ })
40
+
41
+ it('should return null for null', () => {
42
+ expect(serializeJsonField(null)).toBeNull()
43
+ })
44
+
45
+ it('should serialize arrays', () => {
46
+ expect(serializeJsonField([1, 2, 3])).toBe('[1,2,3]')
47
+ })
48
+ })
49
+ })
@@ -0,0 +1,26 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { validatePermissions } from '../permission-utils'
4
+ import { Permission } from '@shared/modules/permission'
5
+
6
+ describe('validatePermissions', () => {
7
+ it('should return true for valid permissions', () => {
8
+ expect(validatePermissions([Permission.USER_VIEW, Permission.USER_CREATE])).toBe(true)
9
+ })
10
+
11
+ it('should return true for empty array', () => {
12
+ expect(validatePermissions([])).toBe(true)
13
+ })
14
+
15
+ it('should return false for invalid permission string', () => {
16
+ expect(validatePermissions(['invalid:permission' as Permission])).toBe(false)
17
+ })
18
+
19
+ it('should return false for mixed valid and invalid', () => {
20
+ expect(validatePermissions([Permission.USER_VIEW, 'invalid:permission' as Permission])).toBe(false)
21
+ })
22
+
23
+ it('should return true for single valid permission', () => {
24
+ expect(validatePermissions([Permission.ORDER_VIEW])).toBe(true)
25
+ })
26
+ })
@@ -0,0 +1,25 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { generateUUID } from '../uuid'
4
+
5
+ describe('generateUUID', () => {
6
+ it('should return a string', () => {
7
+ expect(typeof generateUUID()).toBe('string')
8
+ })
9
+
10
+ it('should match UUID format', () => {
11
+ const uuid = generateUUID()
12
+ expect(uuid).toMatch(
13
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{3,4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
14
+ )
15
+ })
16
+
17
+ it('should generate unique values', () => {
18
+ expect(generateUUID()).not.toBe(generateUUID())
19
+ })
20
+
21
+ it('should generate 100 unique values', () => {
22
+ const uuids = new Set(Array.from({ length: 100 }, () => generateUUID()))
23
+ expect(uuids.size).toBe(100)
24
+ })
25
+ })
@@ -76,6 +76,13 @@ export interface ModuleManifest {
76
76
  files: string[]
77
77
  /** Whether the module has seed data */
78
78
  hasSeed: boolean
79
+ /** Seed function info (required when hasSeed is true) */
80
+ seed?: {
81
+ /** Service file name under module-xxx/services/ (e.g., 'todo-service') */
82
+ serviceFile: string
83
+ /** Seed function name (e.g., 'seedTodosIfEmpty') */
84
+ functionName: string
85
+ }
79
86
  }
80
87
 
81
88
  /** Required npm dependencies (beyond what core provides) */
@@ -99,6 +106,14 @@ export interface ModuleManifest {
99
106
 
100
107
  /** Whether this module has WebSocket routes */
101
108
  hasWebSocket?: boolean
109
+
110
+ /** CLI module mapping — links server module to its CLI command module */
111
+ cliModule?: {
112
+ /** CLI module directory name under src/cli/modules/ (e.g., 'todo') */
113
+ dir: string
114
+ /** Register function name (e.g., 'registerTodoCommands') */
115
+ registerFunction: string
116
+ }
102
117
  }
103
118
 
104
119
  /** Type-safe module registry — maps module name to its manifest */
@@ -79,7 +79,7 @@ export const ClearTodosResultSchema = z.object({
79
79
  deletedCount: z.number(),
80
80
  })
81
81
 
82
- export const SuccessSchema = z.object({})
82
+ export const AdminSuccessSchema = z.object({})
83
83
 
84
84
  export const DownloadTokenSchema = z.object({
85
85
  token: z.string(),