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,292 @@
1
+ import { eq, desc, and } from 'drizzle-orm'
2
+ import type {
3
+ Merchant,
4
+ MerchantLoginInput,
5
+ MerchantStats,
6
+ Product,
7
+ CreateProductInput,
8
+ } from '@shared/schemas'
9
+ import { getDb } from '@server/db'
10
+ import { merchants, products } from '@server/db/schema'
11
+ import { createModuleLoggerSync } from '../../utils/logger'
12
+ import { AuthenticationError } from '../../utils/app-error'
13
+
14
+ const log = createModuleLoggerSync('merchant-service')
15
+
16
+ // ==================== Merchant Functions ====================
17
+
18
+ export async function getMerchantByUserId(userId: string): Promise<Merchant | null> {
19
+ const db = await getDb()
20
+ const rows = await db.select().from(merchants).where(eq(merchants.userId, userId))
21
+
22
+ if (rows.length === 0) return null
23
+
24
+ const row = rows[0]
25
+
26
+ return {
27
+ id: row.id,
28
+ userId: row.userId,
29
+ tenantId: row.tenantId,
30
+ businessName: row.businessName,
31
+ businessType: row.businessType as 'retail' | 'wholesale' | 'service' | 'restaurant',
32
+ status: row.status as 'active' | 'inactive' | 'suspended',
33
+ description: row.description,
34
+ phone: row.phone,
35
+ email: row.email,
36
+ address: row.address,
37
+ createdAt: row.createdAt,
38
+ updatedAt: row.updatedAt,
39
+ }
40
+ }
41
+
42
+ export async function getMerchantById(id: number): Promise<Merchant | null> {
43
+ const db = await getDb()
44
+ const rows = await db.select().from(merchants).where(eq(merchants.id, id))
45
+
46
+ if (rows.length === 0) return null
47
+
48
+ const row = rows[0]
49
+
50
+ return {
51
+ id: row.id,
52
+ userId: row.userId,
53
+ tenantId: row.tenantId,
54
+ businessName: row.businessName,
55
+ businessType: row.businessType as 'retail' | 'wholesale' | 'service' | 'restaurant',
56
+ status: row.status as 'active' | 'inactive' | 'suspended',
57
+ description: row.description,
58
+ phone: row.phone,
59
+ email: row.email,
60
+ address: row.address,
61
+ createdAt: row.createdAt,
62
+ updatedAt: row.updatedAt,
63
+ }
64
+ }
65
+
66
+ export async function merchantLogin(
67
+ input: MerchantLoginInput
68
+ ): Promise<{ token: string; merchant: Merchant }> {
69
+ const db = await getDb()
70
+
71
+ // Find merchant by username
72
+ const rows = await db.select().from(merchants).where(eq(merchants.userId, input.username))
73
+
74
+ if (rows.length === 0) {
75
+ throw new AuthenticationError('Invalid credentials')
76
+ }
77
+
78
+ const merchant = rows[0]
79
+
80
+ // Check password (simplified for demo - use bcrypt in production)
81
+ if (merchant.password !== input.password) {
82
+ throw new AuthenticationError('Invalid credentials')
83
+ }
84
+
85
+ // Check merchant status
86
+ if (merchant.status === 'suspended') {
87
+ throw new AuthenticationError('Account is suspended')
88
+ }
89
+
90
+ if (merchant.status === 'inactive') {
91
+ throw new AuthenticationError('Account is inactive')
92
+ }
93
+
94
+ // Generate token (simplified for demo - use JWT in production)
95
+ const token = `merchant-token-${merchant.id}-${Date.now()}`
96
+
97
+ log.info({ merchantId: merchant.id }, 'Merchant logged in')
98
+
99
+ const merchantData: Merchant = {
100
+ id: merchant.id,
101
+ userId: merchant.userId,
102
+ tenantId: merchant.tenantId,
103
+ businessName: merchant.businessName,
104
+ businessType: merchant.businessType as 'retail' | 'wholesale' | 'service' | 'restaurant',
105
+ status: merchant.status as 'active' | 'inactive' | 'suspended',
106
+ description: merchant.description,
107
+ phone: merchant.phone,
108
+ email: merchant.email,
109
+ address: merchant.address,
110
+ createdAt: merchant.createdAt,
111
+ updatedAt: merchant.updatedAt,
112
+ }
113
+
114
+ return { token, merchant: merchantData }
115
+ }
116
+
117
+ export async function getMerchantStats(merchantId: number): Promise<MerchantStats> {
118
+ const db = await getDb()
119
+
120
+ // Get product counts
121
+ const allProducts = await db.select().from(products).where(eq(products.merchantId, merchantId))
122
+ const activeProducts = allProducts.filter(p => p.status === 'active').length
123
+
124
+ // Get order stats (simplified - in production, query orders table)
125
+ const totalOrders = 0 // TODO: Implement order stats
126
+ const totalRevenue = 0 // TODO: Implement revenue stats
127
+ const pendingOrders = 0 // TODO: Implement pending order count
128
+ const thisMonthRevenue = 0 // TODO: Implement monthly revenue
129
+
130
+ return {
131
+ totalOrders,
132
+ totalRevenue,
133
+ totalProducts: allProducts.length,
134
+ activeProducts,
135
+ pendingOrders,
136
+ thisMonthRevenue,
137
+ }
138
+ }
139
+
140
+ // ==================== Product Functions ====================
141
+
142
+ export async function listProducts(
143
+ merchantId: number,
144
+ page = 1,
145
+ pageSize = 20,
146
+ status?: 'active' | 'inactive' | 'out_of_stock'
147
+ ): Promise<{ items: Product[]; total: number; page: number; pageSize: number }> {
148
+ const db = await getDb()
149
+ const offset = (page - 1) * pageSize
150
+
151
+ const whereConditions = [eq(products.merchantId, merchantId)]
152
+ if (status) {
153
+ whereConditions.push(eq(products.status, status))
154
+ }
155
+
156
+ const baseQuery = db
157
+ .select()
158
+ .from(products)
159
+ .where(and(...whereConditions))
160
+
161
+ const rows = await baseQuery.orderBy(desc(products.createdAt)).limit(pageSize).offset(offset)
162
+
163
+ const countRows = await baseQuery
164
+ const total = countRows.length
165
+
166
+ const items: Product[] = rows.map(row => ({
167
+ id: row.id,
168
+ name: row.name,
169
+ description: row.description || '',
170
+ price: row.price,
171
+ status: row.status as 'active' | 'inactive' | 'out_of_stock',
172
+ stock: row.stock,
173
+ imageUrl: row.imageUrl,
174
+ createdAt: row.createdAt.toISOString(),
175
+ updatedAt: row.updatedAt.toISOString(),
176
+ }))
177
+
178
+ return { items, total, page, pageSize }
179
+ }
180
+
181
+ export async function createProduct(
182
+ merchantId: number,
183
+ input: CreateProductInput
184
+ ): Promise<Product> {
185
+ const db = await getDb()
186
+
187
+ const now = new Date()
188
+ const productId = `product-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
189
+
190
+ const result = await db
191
+ .insert(products)
192
+ .values({
193
+ id: productId,
194
+ merchantId,
195
+ name: input.name,
196
+ description: input.description || null,
197
+ price: input.price,
198
+ status: input.status || 'active',
199
+ stock: input.stock || 0,
200
+ imageUrl: input.imageUrl || null,
201
+ createdAt: now,
202
+ updatedAt: now,
203
+ })
204
+ .returning()
205
+
206
+ const row = result[0]
207
+
208
+ log.info({ productId, merchantId }, 'Product created')
209
+
210
+ return {
211
+ id: row.id,
212
+ name: row.name,
213
+ description: row.description || '',
214
+ price: row.price,
215
+ status: row.status as 'active' | 'inactive' | 'out_of_stock',
216
+ stock: row.stock,
217
+ imageUrl: row.imageUrl,
218
+ createdAt: row.createdAt.toISOString(),
219
+ updatedAt: row.updatedAt.toISOString(),
220
+ }
221
+ }
222
+
223
+ export async function getProductById(productId: string): Promise<Product | null> {
224
+ const db = await getDb()
225
+ const rows = await db.select().from(products).where(eq(products.id, productId))
226
+
227
+ if (rows.length === 0) return null
228
+
229
+ const row = rows[0]
230
+
231
+ return {
232
+ id: row.id,
233
+ name: row.name,
234
+ description: row.description || '',
235
+ price: row.price,
236
+ status: row.status as 'active' | 'inactive' | 'out_of_stock',
237
+ stock: row.stock,
238
+ imageUrl: row.imageUrl,
239
+ createdAt: row.createdAt.toISOString(),
240
+ updatedAt: row.updatedAt.toISOString(),
241
+ }
242
+ }
243
+
244
+ // ==================== Seed Functions ====================
245
+
246
+ const seedMerchants = [
247
+ {
248
+ userId: 'merchant-1',
249
+ tenantId: 1,
250
+ businessName: 'Demo Store',
251
+ businessType: 'retail' as const,
252
+ status: 'active' as const,
253
+ description: 'A demo retail store',
254
+ phone: '13800138000',
255
+ email: 'merchant@example.com',
256
+ address: '123 Main St',
257
+ password: 'password123',
258
+ },
259
+ {
260
+ userId: 'merchant-2',
261
+ tenantId: 1,
262
+ businessName: 'Tech Shop',
263
+ businessType: 'service' as const,
264
+ status: 'active' as const,
265
+ description: null,
266
+ phone: null,
267
+ email: null,
268
+ address: null,
269
+ password: 'password123',
270
+ },
271
+ ]
272
+
273
+ export async function seedMerchantsIfEmpty(): Promise<void> {
274
+ const db = await getDb()
275
+
276
+ const existing = await db.select().from(merchants).limit(1)
277
+ if (existing.length > 0) {
278
+ log.info({}, 'Merchants already seeded, skipping')
279
+ return
280
+ }
281
+
282
+ const now = new Date().toISOString()
283
+ for (const merchant of seedMerchants) {
284
+ await db.insert(merchants).values({
285
+ ...merchant,
286
+ createdAt: now,
287
+ updatedAt: now,
288
+ })
289
+ }
290
+
291
+ log.info({ count: seedMerchants.length }, 'Merchants seeded')
292
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Notification Module Public API
3
+ *
4
+ * This is the public interface for the notification module.
5
+ * Other modules should import from here, not from internal service paths.
6
+ */
7
+ export { notificationRoutes } from './routes/notification-routes'
8
+ export {
9
+ listNotifications,
10
+ getNotification,
11
+ createNotification,
12
+ createNotificationAndBroadcast,
13
+ markAsRead,
14
+ markAllAsRead,
15
+ deleteNotification,
16
+ getUnreadCount,
17
+ clearAllNotifications,
18
+ } from './services/notification-service'
@@ -27,6 +27,8 @@ const notificationManifest: ModuleManifest = {
27
27
  },
28
28
 
29
29
  hasSSE: true,
30
+
31
+ cliModule: { dir: 'notification', registerFunction: 'registerNotificationCommands' },
30
32
  }
31
33
 
32
34
  export default notificationManifest
@@ -39,6 +39,7 @@ const orderManifest: ModuleManifest = {
39
39
  dbSchemas: {
40
40
  files: ['orders'],
41
41
  hasSeed: true,
42
+ seed: { serviceFile: 'order-service', functionName: 'seedOrdersIfEmpty' },
42
43
  },
43
44
  }
44
45
 
@@ -10,7 +10,7 @@ import {
10
10
  CreateOrderSchema,
11
11
  UpdateOrderSchema,
12
12
  OrderListSchema,
13
- DeleteResultSchema,
13
+ OrderDeleteResultSchema,
14
14
  OrderQuerySchema,
15
15
  } from '@shared/modules/order'
16
16
 
@@ -109,7 +109,7 @@ const deleteRoute = createRoute({
109
109
  params: OrderSchema.pick({ id: true }),
110
110
  },
111
111
  responses: {
112
- 200: successResponse(DeleteResultSchema, 'Order deleted'),
112
+ 200: successResponse(OrderDeleteResultSchema, 'Order deleted'),
113
113
  401: errorResponse('Unauthorized'),
114
114
  403: errorResponse('Forbidden'),
115
115
  404: errorResponse('Order not found'),
@@ -11,7 +11,7 @@ import {
11
11
  CreateRoleSchema,
12
12
  UpdateRoleSchema,
13
13
  UpdateRolePermissionsSchema,
14
- SuccessSchema,
14
+ RoleSuccessSchema,
15
15
  } from '@shared/modules/role/schemas'
16
16
  import { validatePermissionDependencies } from '@shared/modules/permission/permission-dependencies'
17
17
 
@@ -112,7 +112,7 @@ const deleteRoleRoute = createRoute({
112
112
  }),
113
113
  },
114
114
  responses: {
115
- 200: successResponse(SuccessSchema, 'Role deleted'),
115
+ 200: successResponse(RoleSuccessSchema, 'Role deleted'),
116
116
  401: errorResponse('Unauthorized'),
117
117
  403: errorResponse('Forbidden'),
118
118
  400: errorResponse('Cannot delete system role'),
@@ -138,7 +138,7 @@ const updateRolePermissionsRoute = createRoute({
138
138
  },
139
139
  },
140
140
  responses: {
141
- 200: successResponse(SuccessSchema, 'Role permissions updated'),
141
+ 200: successResponse(RoleSuccessSchema, 'Role permissions updated'),
142
142
  400: errorResponse('Permission dependency validation failed'),
143
143
  401: errorResponse('Unauthorized'),
144
144
  403: errorResponse('Forbidden'),
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
2
+ import * as categoryService from '../services/admin-category-service'
3
+ import { getRawClient, getDb } from '@server/db'
4
+ import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
5
+ import { NotFoundError, ConflictError } from '@server/utils/app-error'
6
+
7
+ async function clearPluginTables() {
8
+ const client = await getRawClient()
9
+ if (client && 'execute' in client) {
10
+ await client.execute('DELETE FROM plugin_category_mappings')
11
+ await client.execute('DELETE FROM plugins')
12
+ await client.execute('DELETE FROM plugin_categories')
13
+ }
14
+ }
15
+
16
+ async function insertTestCategory(overrides: Record<string, unknown> = {}) {
17
+ const client = await getRawClient()
18
+ if (!client || !('execute' in client)) throw new Error('No DB client')
19
+
20
+ const id = (overrides.id ?? `cat-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`) as string
21
+ await client.execute({
22
+ sql: `INSERT INTO plugin_categories (id, name, slug, description, icon, sort_order)
23
+ VALUES (?, ?, ?, ?, ?, ?)`,
24
+ args: [
25
+ id,
26
+ (overrides.name ?? 'Test Category') as string,
27
+ (overrides.slug ?? id) as string,
28
+ (overrides.description ?? null) as string | null,
29
+ (overrides.icon ?? null) as string | null,
30
+ (overrides.sortOrder ?? 0) as number,
31
+ ],
32
+ })
33
+ return id
34
+ }
35
+
36
+ describe('Admin Category Service', () => {
37
+ beforeAll(async () => {
38
+ await setupTestDatabase()
39
+ const db = await getDb()
40
+ expect(db).toBeDefined()
41
+ })
42
+
43
+ afterAll(async () => {
44
+ await cleanupTestDatabase()
45
+ })
46
+
47
+ beforeEach(async () => {
48
+ await clearPluginTables()
49
+ })
50
+
51
+ afterEach(async () => {
52
+ await clearPluginTables()
53
+ vi.clearAllMocks()
54
+ })
55
+
56
+ describe('createCategory', () => {
57
+ it('should create a category', async () => {
58
+ const category = await categoryService.createCategory({
59
+ name: 'UI & Design',
60
+ slug: 'ui-design',
61
+ description: 'Visual customization',
62
+ icon: 'palette',
63
+ })
64
+
65
+ expect(category.id).toBeDefined()
66
+ expect(category.name).toBe('UI & Design')
67
+ expect(category.slug).toBe('ui-design')
68
+ expect(category.description).toBe('Visual customization')
69
+ expect(category.icon).toBe('palette')
70
+ expect(category.sortOrder).toBe(0)
71
+ })
72
+
73
+ it('should create a category without optional fields', async () => {
74
+ const category = await categoryService.createCategory({
75
+ name: 'Basic',
76
+ slug: 'basic',
77
+ })
78
+
79
+ expect(category.name).toBe('Basic')
80
+ expect(category.description).toBeUndefined()
81
+ expect(category.icon).toBeUndefined()
82
+ })
83
+
84
+ it('should throw ConflictError for duplicate slug', async () => {
85
+ await categoryService.createCategory({ name: 'First', slug: 'dup-slug' })
86
+
87
+ await expect(
88
+ categoryService.createCategory({ name: 'Second', slug: 'dup-slug' })
89
+ ).rejects.toThrow(ConflictError)
90
+ })
91
+ })
92
+
93
+ describe('updateCategory', () => {
94
+ it('should update category name', async () => {
95
+ const catId = await insertTestCategory({ name: 'Old Name', slug: 'update-name' })
96
+
97
+ const updated = await categoryService.updateCategory(catId, { name: 'New Name' })
98
+
99
+ expect(updated.name).toBe('New Name')
100
+ expect(updated.slug).toBe('update-name')
101
+ })
102
+
103
+ it('should update category slug', async () => {
104
+ const catId = await insertTestCategory({ name: 'My Cat', slug: 'old-slug' })
105
+
106
+ const updated = await categoryService.updateCategory(catId, { slug: 'new-slug' })
107
+
108
+ expect(updated.slug).toBe('new-slug')
109
+ })
110
+
111
+ it('should update multiple fields at once', async () => {
112
+ const catId = await insertTestCategory({ name: 'Cat', slug: 'multi-update' })
113
+
114
+ const updated = await categoryService.updateCategory(catId, {
115
+ name: 'Updated Cat',
116
+ description: 'New description',
117
+ icon: 'new-icon',
118
+ sortOrder: 5,
119
+ })
120
+
121
+ expect(updated.name).toBe('Updated Cat')
122
+ expect(updated.description).toBe('New description')
123
+ expect(updated.icon).toBe('new-icon')
124
+ expect(updated.sortOrder).toBe(5)
125
+ })
126
+
127
+ it('should set description to null', async () => {
128
+ const catId = await insertTestCategory({
129
+ name: 'Desc Cat',
130
+ slug: 'desc-update',
131
+ description: 'Has description',
132
+ })
133
+
134
+ const updated = await categoryService.updateCategory(catId, { description: null })
135
+
136
+ expect(updated.description).toBeUndefined()
137
+ })
138
+
139
+ it('should throw NotFoundError for non-existent category', async () => {
140
+ await expect(
141
+ categoryService.updateCategory('non-existent', { name: 'X' })
142
+ ).rejects.toThrow(NotFoundError)
143
+ })
144
+ })
145
+
146
+ describe('deleteCategory', () => {
147
+ it('should delete a category', async () => {
148
+ const catId = await insertTestCategory({ name: 'Delete Me', slug: 'delete-cat' })
149
+
150
+ await categoryService.deleteCategory(catId)
151
+
152
+ const categories = await categoryService.listAllCategories()
153
+ expect(categories.find(c => c.id === catId)).toBeUndefined()
154
+ })
155
+
156
+ it('should throw NotFoundError for non-existent category', async () => {
157
+ await expect(categoryService.deleteCategory('non-existent')).rejects.toThrow(NotFoundError)
158
+ })
159
+ })
160
+
161
+ describe('listAllCategories', () => {
162
+ it('should return categories sorted by sortOrder', async () => {
163
+ await insertTestCategory({ name: 'Third', slug: 'third', sortOrder: 3 })
164
+ await insertTestCategory({ name: 'First', slug: 'first', sortOrder: 1 })
165
+ await insertTestCategory({ name: 'Second', slug: 'second', sortOrder: 2 })
166
+
167
+ const categories = await categoryService.listAllCategories()
168
+
169
+ expect(categories).toHaveLength(3)
170
+ expect(categories[0].name).toBe('First')
171
+ expect(categories[1].name).toBe('Second')
172
+ expect(categories[2].name).toBe('Third')
173
+ })
174
+
175
+ it('should return empty array when no categories', async () => {
176
+ const categories = await categoryService.listAllCategories()
177
+
178
+ expect(categories).toEqual([])
179
+ })
180
+ })
181
+ })