create-fullstack-scaffold 0.4.25 → 0.5.1

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 (137) hide show
  1. package/dist/cli/index.js +432 -15
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +10 -13
  4. package/template/drizzle.config.ts +10 -4
  5. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  6. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  7. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  8. package/template/eslint.config.js +3 -0
  9. package/template/package.json +9 -6
  10. package/template/scripts/sync-agent-hooks.mjs +189 -0
  11. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  12. package/template/src/admin/pages/ContentPage.tsx +1 -1
  13. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  14. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  15. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  16. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  17. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  18. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  19. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  20. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  21. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  22. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  23. package/template/src/admin/services/apiClient.ts +2 -3
  24. package/template/src/cli/modules/content/index.ts +3 -3
  25. package/template/src/cli/modules/dispute/index.ts +3 -3
  26. package/template/src/cli/modules/ticket/index.ts +3 -3
  27. package/template/src/cli/modules/todo/index.ts +1 -1
  28. package/template/src/cli/rpc/client.ts +3 -4
  29. package/template/src/cli/rpc/index.ts +1 -1
  30. package/template/src/client/App.tsx +3 -39
  31. package/template/src/client/AppRoutes.tsx +53 -0
  32. package/template/src/client/entry-server.tsx +75 -0
  33. package/template/src/client/main.tsx +3 -1
  34. package/template/src/client/pages/SearchPage.tsx +1 -1
  35. package/template/src/client/services/apiClient.ts +12 -4
  36. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  37. package/template/src/client/stores/entry-stores.ts +36 -0
  38. package/template/src/client/stores/notificationStore.ts +4 -4
  39. package/template/src/client/stores/todoStore.ts +2 -2
  40. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  41. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  42. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  43. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  44. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  45. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  46. package/template/src/server/app.ts +6 -4
  47. package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
  48. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  49. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  50. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  51. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  52. package/template/src/server/core/isr-cache.ts +61 -18
  53. package/template/src/server/core/isr-invalidation.ts +6 -12
  54. package/template/src/server/core/isr-registry.ts +104 -0
  55. package/template/src/server/core/isr-renderer.ts +75 -0
  56. package/template/src/server/db/schema/contents.ts +28 -20
  57. package/template/src/server/db/schema/disputes.ts +30 -22
  58. package/template/src/server/db/schema/notifications.ts +20 -14
  59. package/template/src/server/db/schema/orders.ts +23 -16
  60. package/template/src/server/db/schema/plugins.ts +56 -38
  61. package/template/src/server/db/schema/products.ts +24 -17
  62. package/template/src/server/db/schema/tickets.ts +44 -31
  63. package/template/src/server/db/schema/todos.ts +26 -18
  64. package/template/src/server/entries/cloudflare.ts +97 -21
  65. package/template/src/server/entries/node.ts +0 -2
  66. package/template/src/server/index.ts +0 -1
  67. package/template/src/server/isr-modules.ts +10 -0
  68. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  69. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  70. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  71. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  72. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  73. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  74. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  75. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  76. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  77. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  78. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  79. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  80. package/template/src/server/module-content/isr.ts +63 -0
  81. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  82. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  83. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  84. package/template/src/server/module-content/services/content-service.ts +23 -10
  85. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  86. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  87. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  88. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  89. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  90. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  91. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  92. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  93. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  94. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  95. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  96. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  97. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  98. package/template/src/server/module-order/services/order-service.ts +25 -13
  99. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  100. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  101. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  102. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  103. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  104. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  105. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  106. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  107. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  108. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  109. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  110. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  111. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  112. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  113. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  114. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  115. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  116. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  117. package/template/src/server/module-todos/isr.ts +41 -0
  118. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  119. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  120. package/template/src/server/route-registry.ts +0 -4
  121. package/template/src/server/rpc-merge.ts +27 -0
  122. package/template/src/server/rpc-surface.ts +117 -0
  123. package/template/src/server/rpc-type-canary.ts +80 -0
  124. package/template/src/server/test-utils/test-client.ts +7 -9
  125. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  126. package/template/src/shared/modules/content/schemas.ts +14 -0
  127. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  128. package/template/src/shared/modules/order/schemas.ts +9 -1
  129. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  130. package/template/src/shared/modules/todos/index.ts +4 -0
  131. package/template/src/shared/modules/todos/schemas.ts +14 -0
  132. package/template/src/shared/schemas/index.ts +18 -0
  133. package/template/tsup.config.ts +48 -1
  134. package/template/vitest.config.ts +19 -3
  135. package/template/vitest.setup.ts +68 -5
  136. package/template/patches/typescript+5.9.3.patch +0 -24
  137. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -28,9 +28,12 @@ describe('Integration: Todos API (Real Database)', () => {
28
28
  it('should handle complete todo lifecycle', async () => {
29
29
  const client = createTestClient(undefined, { headers: authHeaders })
30
30
 
31
- const listRes = await client.api.todos.$get({ headers: authHeaders })
31
+ const listRes = await client.api.todos.$get({ query: {} })
32
32
  const listData = await listRes.json()
33
- expect(listData).toMatchObject({ success: true, data: [] })
33
+ expect(listData).toMatchObject({
34
+ success: true,
35
+ data: { todos: [], total: 0, page: 1, limit: 20 },
36
+ })
34
37
 
35
38
  const createRes = await client.api.todos.$post(
36
39
  {
@@ -102,10 +105,10 @@ describe('Integration: Todos API (Real Database)', () => {
102
105
  expect(res.status).toBe(201)
103
106
  })
104
107
 
105
- const listRes = await client.api.todos.$get({ headers: authHeaders })
108
+ const listRes = await client.api.todos.$get({ query: {} })
106
109
  const listData = await listRes.json()
107
110
  if (listData.success && 'data' in listData) {
108
- expect(listData.data).toHaveLength(10)
111
+ expect(listData.data.todos).toHaveLength(10)
109
112
  }
110
113
  })
111
114
  })
@@ -42,7 +42,11 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
42
42
  return c.json({ status: 'ok', timestamp: new Date().toISOString(), db: 'not configured' })
43
43
  }
44
44
  })
45
- .post('/api/__test__/cleanup', async c => {
45
+
46
+ // 测试辅助端点:仅开发/测试环境注册。生产(含 Cloudflare 构建——tsup 将
47
+ // process.env.NODE_ENV 静态替换为 "production")不暴露这个清库端点。
48
+ if (process.env.NODE_ENV !== 'production') {
49
+ app.post('/api/__test__/cleanup', async c => {
46
50
  try {
47
51
  const { cleanupTestDatabase } = await import('./db/test-setup')
48
52
  await cleanupTestDatabase()
@@ -52,6 +56,7 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
52
56
  return c.json({ success: false as const, message: 'Failed to cleanup database' }, 500)
53
57
  }
54
58
  })
59
+ }
55
60
 
56
61
  autoRegisterRealtime(app as unknown as Parameters<typeof autoRegisterRealtime>[0])
57
62
 
@@ -100,6 +105,3 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
100
105
 
101
106
  return app
102
107
  }
103
- export type AdminApiType = typeof adminApiRoutes
104
- export type ClientApiType = typeof clientApiRoutes
105
- export type AppType = ReturnType<typeof createApp>
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @framework-baseline 4fce8de3d919d2fb
3
+
4
+ * CloudflareCacheStore 单测:索引清单(manifest)驱动的 purgePattern。
5
+ *
6
+ * 背景:Cache API 没有 list(),purgePattern 曾是恒不执行的死代码——
7
+ * CF 生产上内容更新后陈旧详情页服务到自然过期。现实现用 isr:__index__
8
+ * 键记录已缓存 pathname,pattern 据此真清剿。此文件验证该行为。
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
12
+
13
+ /** 极简 Cache API mock:Map<urlString, Response> */
14
+ function makeFakeCaches() {
15
+ const store = new Map<string, Response>()
16
+ const cache = {
17
+ async match(req: Request | string) {
18
+ const key = typeof req === 'string' ? req : req.url
19
+ return store.get(key) ?? undefined
20
+ },
21
+ async put(req: Request | string, res: Response) {
22
+ const key = typeof req === 'string' ? req : req.url
23
+ store.set(key, res)
24
+ },
25
+ async delete(req: Request | string) {
26
+ const key = typeof req === 'string' ? req : req.url
27
+ return store.delete(key)
28
+ },
29
+ }
30
+ return { caches: { open: async () => cache }, store }
31
+ }
32
+
33
+ describe('CloudflareCacheStore purgePattern (manifest)', () => {
34
+ let fake: ReturnType<typeof makeFakeCaches>
35
+ let originalCaches: unknown
36
+
37
+ beforeEach(async () => {
38
+ fake = makeFakeCaches()
39
+ originalCaches = (globalThis as { caches?: unknown }).caches
40
+ ;(globalThis as { caches?: unknown }).caches = fake.caches as never
41
+ const mod = await import('@server/core/isr-cache')
42
+ // 重新构造以绑定 mock caches
43
+ const { createISRCache } = mod
44
+ const cache = createISRCache({ maxAge: 60, staleWhileRevalidate: 60 })
45
+ ;(globalThis as Record<string, unknown>).__testCache = cache
46
+ })
47
+
48
+ afterEach(() => {
49
+ ;(globalThis as { caches?: unknown }).caches = originalCaches as never
50
+ })
51
+
52
+ function getCache() {
53
+ return (globalThis as Record<string, unknown>).__testCache as {
54
+ store: (key: string, html: string) => Promise<void>
55
+ purgePattern: (pattern: string) => Promise<void>
56
+ lookup: (pathname: string) => Promise<{ status: string }>
57
+ }
58
+ }
59
+
60
+ it('purgePattern 清剿匹配键并保留其余(索引驱动,非死代码)', async () => {
61
+ const c = getCache()
62
+ await c.store('/', 'home')
63
+ await c.store('/content', 'list')
64
+ await c.store('/content/123', 'detail-123')
65
+ await c.store('/content/456', 'detail-456')
66
+ await c.store('/todos', 'todos')
67
+
68
+ await c.purgePattern('isr:/content/*')
69
+
70
+ expect((await c.lookup('/content/123')).status).toBe('miss')
71
+ expect((await c.lookup('/content/456')).status).toBe('miss')
72
+ // 列表页本身不匹配 isr:/content/*(无尾斜杠通配不到裸键)
73
+ expect((await c.lookup('/todos')).status).toBe('fresh')
74
+ })
75
+
76
+ it('正则元字符键名不误伤(escapeForPattern)', async () => {
77
+ const c = getCache()
78
+ await c.store('/content/1+2', 'plus')
79
+ await c.store('/content/abc', 'plain')
80
+
81
+ // '1+2' 中的 + 若未转义会被当量词,误匹配 '12' 等——这里验证只清目标
82
+ await c.purgePattern('isr:/content/1+2')
83
+
84
+ expect((await c.lookup('/content/1+2')).status).toBe('miss')
85
+ expect((await c.lookup('/content/abc')).status).toBe('fresh')
86
+ })
87
+
88
+ it('purge 单键后索引同步收缩,后续 purgePattern 不再删幽灵键', async () => {
89
+ const c = getCache()
90
+ await c.store('/a', 'a')
91
+ await c.store('/b', 'b')
92
+ await c.purgePattern('isr:/a')
93
+ // 无异常即通过(索引写回路径被走到)
94
+ expect((await c.lookup('/b')).status).toBe('fresh')
95
+ })
96
+ })
@@ -1,117 +1,121 @@
1
1
  /**
2
- * @framework-baseline dd5cd4fe46320b03
3
- * @framework-modify
4
- * @reason 移除未使用的 vi import,修复 TypeScript strict 检查
5
- * @impact 不影响功能,仅清理代码
2
+ * @framework-baseline 68a6e13bffb67f13
6
3
  */
7
4
 
8
5
  import { describe, it, expect, beforeEach } from 'vitest'
9
- import { isISRRoute, generateCacheKey, createISRCache, type ISRCache } from '@server/core/isr-cache'
10
-
11
- describe('isISRRoute', () => {
12
- it('matches static ISR routes', () => {
13
- expect(isISRRoute('/')).toBe(true)
14
- expect(isISRRoute('/todos')).toBe(true)
15
- expect(isISRRoute('/content')).toBe(true)
16
- expect(isISRRoute('/notifications')).toBe(true)
17
- expect(isISRRoute('/websocket')).toBe(true)
18
- })
19
-
20
- it('matches dynamic content routes', () => {
21
- expect(isISRRoute('/content/123')).toBe(true)
22
- expect(isISRRoute('/content/content-456')).toBe(true)
23
- expect(isISRRoute('/content/abc-def')).toBe(true)
24
- })
25
-
26
- it('does not match non-ISR routes', () => {
27
- expect(isISRRoute('/api/todos')).toBe(false)
28
- expect(isISRRoute('/api/health')).toBe(false)
29
- expect(isISRRoute('/assets/main.js')).toBe(false)
30
- expect(isISRRoute('/admin/dashboard')).toBe(false)
31
- expect(isISRRoute('/health')).toBe(false)
32
- expect(isISRRoute('/vite.svg')).toBe(false)
33
- })
34
- })
35
-
36
- describe('generateCacheKey', () => {
37
- it('normalizes root path', () => {
38
- expect(generateCacheKey('/')).toBe('isr:/index')
39
- })
40
-
41
- it('generates keys for regular paths', () => {
42
- expect(generateCacheKey('/todos')).toBe('isr:/todos')
43
- expect(generateCacheKey('/content/123')).toBe('isr:/content/123')
44
- })
6
+ import { createISRCache } from '@server/core/isr-cache'
45
7
 
46
- it('removes trailing slash', () => {
47
- expect(generateCacheKey('/todos/')).toBe('isr:/todos')
48
- })
49
- })
50
-
51
- describe('ISRCache (memory)', () => {
52
- let cache: ISRCache
8
+ describe('ISR Cache', () => {
9
+ let cache: ReturnType<typeof createISRCache>
53
10
 
54
11
  beforeEach(() => {
55
- cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 1 })
12
+ cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 2 })
56
13
  })
57
14
 
58
- it('returns miss for uncached routes', async () => {
59
- const result = await cache.lookup('/todos')
60
- expect(result.status).toBe('miss')
61
- expect(result.html).toBeNull()
15
+ describe('lookup no cache', () => {
16
+ it('should return miss for uncached path', async () => {
17
+ const result = await cache.lookup('/todos')
18
+ expect(result.status).toBe('miss')
19
+ expect(result.html).toBeNull()
20
+ })
62
21
  })
63
22
 
64
- it('stores and retrieves pages', async () => {
65
- await cache.store('/todos', '<html>todos</html>')
66
- const result = await cache.lookup('/todos')
67
- expect(result.status).toBe('fresh')
68
- expect(result.html).toBe('<html>todos</html>')
23
+ describe('store lookup fresh', () => {
24
+ it('should return fresh after store', async () => {
25
+ await cache.store('/todos', '<html>todos</html>')
26
+ const result = await cache.lookup('/todos')
27
+ expect(result.status).toBe('fresh')
28
+ expect(result.html).toBe('<html>todos</html>')
29
+ })
69
30
  })
70
31
 
71
- it('serves stale content after maxAge', async () => {
72
- cache = createISRCache({ maxAge: 0, staleWhileRevalidate: 60 })
73
-
74
- await cache.store('/todos', '<html>todos</html>')
75
-
76
- await new Promise(resolve => setTimeout(resolve, 10))
32
+ describe('store wait lookup stale', () => {
33
+ it('should return stale after maxAge', async () => {
34
+ await cache.store('/todos', '<html>todos</html>')
35
+ // Wait beyond maxAge (1s) but within staleWhileRevalidate (1+2=3s)
36
+ await new Promise(r => setTimeout(r, 1200))
37
+ const result = await cache.lookup('/todos')
38
+ expect(result.status).toBe('stale')
39
+ expect(result.html).toBe('<html>todos</html>')
40
+ })
41
+ })
77
42
 
78
- const result = await cache.lookup('/todos')
79
- expect(result.status).toBe('stale')
80
- expect(result.html).toBe('<html>todos</html>')
43
+ describe('store wait lookup miss (expired)', () => {
44
+ it('should return miss after maxAge + staleWhileRevalidate', async () => {
45
+ await cache.store('/todos', '<html>todos</html>')
46
+ // Wait beyond both maxAge and staleWhileRevalidate
47
+ await new Promise(r => setTimeout(r, 3500))
48
+ const result = await cache.lookup('/todos')
49
+ expect(result.status).toBe('miss')
50
+ expect(result.html).toBeNull()
51
+ })
81
52
  })
82
53
 
83
- it('returns miss after maxAge + staleWhileRevalidate', async () => {
84
- cache = createISRCache({ maxAge: 0, staleWhileRevalidate: 0 })
54
+ describe('purge', () => {
55
+ it('should purge specific path', async () => {
56
+ await cache.store('/todos', '<html>todos</html>')
57
+ await cache.store('/content', '<html>content</html>')
85
58
 
86
- await cache.store('/todos', '<html>todos</html>')
59
+ await cache.purge('/todos')
87
60
 
88
- await new Promise(resolve => setTimeout(resolve, 10))
61
+ expect((await cache.lookup('/todos')).status).toBe('miss')
62
+ expect((await cache.lookup('/content')).status).toBe('fresh')
63
+ })
89
64
 
90
- const result = await cache.lookup('/todos')
91
- expect(result.status).toBe('miss')
65
+ it('should not error on purge of uncached path', async () => {
66
+ await expect(cache.purge('/never-cached')).resolves.not.toThrow()
67
+ })
92
68
  })
93
69
 
94
- it('purges specific pages', async () => {
95
- await cache.store('/todos', '<html>todos</html>')
96
- await cache.store('/content', '<html>content</html>')
97
-
98
- await cache.purge('/todos')
99
-
100
- expect((await cache.lookup('/todos')).status).toBe('miss')
101
- expect((await cache.lookup('/content')).status).toBe('fresh')
70
+ describe('purgePattern', () => {
71
+ it('should purge matching pattern', async () => {
72
+ await cache.store('/content', '<html>list</html>')
73
+ await cache.store('/content/article-1', '<html>a1</html>')
74
+ await cache.store('/content/article-2', '<html>a2</html>')
75
+ await cache.store('/todos', '<html>todos</html>')
76
+
77
+ await cache.purgePattern('isr:/content/*')
78
+
79
+ // /content (exact) should still be cached
80
+ expect((await cache.lookup('/content')).status).toBe('fresh')
81
+ // /content/* should be purged
82
+ expect((await cache.lookup('/content/article-1')).status).toBe('miss')
83
+ expect((await cache.lookup('/content/article-2')).status).toBe('miss')
84
+ // /todos should be unaffected
85
+ expect((await cache.lookup('/todos')).status).toBe('fresh')
86
+ })
102
87
  })
103
88
 
104
- it('purges by pattern', async () => {
105
- await cache.store('/content', '<html>list</html>')
106
- await cache.store('/content/123', '<html>detail 123</html>')
107
- await cache.store('/content/456', '<html>detail 456</html>')
108
- await cache.store('/todos', '<html>todos</html>')
109
-
110
- await cache.purgePattern('isr:/content/*')
111
-
112
- expect((await cache.lookup('/content')).status).toBe('fresh')
113
- expect((await cache.lookup('/content/123')).status).toBe('miss')
114
- expect((await cache.lookup('/content/456')).status).toBe('miss')
115
- expect((await cache.lookup('/todos')).status).toBe('fresh')
89
+ describe('full lifecycle', () => {
90
+ it('miss store → fresh → purge → miss', async () => {
91
+ // Step 1: Initial miss
92
+ let result = await cache.lookup('/todos')
93
+ expect(result.status).toBe('miss')
94
+
95
+ // Step 2: Store
96
+ await cache.store('/todos', '<html>todos v1</html>')
97
+
98
+ // Step 3: Fresh hit
99
+ result = await cache.lookup('/todos')
100
+ expect(result.status).toBe('fresh')
101
+ expect(result.html).toBe('<html>todos v1</html>')
102
+
103
+ // Step 4: Purge
104
+ await cache.purge('/todos')
105
+
106
+ // Step 5: Miss again
107
+ result = await cache.lookup('/todos')
108
+ expect(result.status).toBe('miss')
109
+ })
110
+
111
+ it('store v1 → purge → store v2 → returns v2', async () => {
112
+ await cache.store('/todos', '<html>v1</html>')
113
+ await cache.purge('/todos')
114
+ await cache.store('/todos', '<html>v2</html>')
115
+
116
+ const result = await cache.lookup('/todos')
117
+ expect(result.status).toBe('fresh')
118
+ expect(result.html).toBe('<html>v2</html>')
119
+ })
116
120
  })
117
121
  })
@@ -15,6 +15,25 @@ import {
15
15
  } from '@server/core/isr-invalidation'
16
16
  import { createISRCache } from '@server/core/isr-cache'
17
17
 
18
+ // Side-effect: 注册 ISR 路由到全局 registry(purgeAllPages 遍历它)。
19
+ // todos/content 在部分 preset 中不存在 —— 均为可选导入
20
+ const todosIsrModule = '@server/module-todos/isr'
21
+ let hasTodosModule = false
22
+ try {
23
+ await import(/* @vite-ignore */ todosIsrModule)
24
+ hasTodosModule = true
25
+ } catch {
26
+ // preset 无 todos 模块(forum/xbrowser 等)
27
+ }
28
+ const contentIsrModule = '@server/module-content/isr'
29
+ let hasContentModule = false
30
+ try {
31
+ await import(/* @vite-ignore */ contentIsrModule)
32
+ hasContentModule = true
33
+ } catch {
34
+ // preset 无 content 模块
35
+ }
36
+
18
37
  describe('isr-invalidation', () => {
19
38
  it('returns null cache when not set', () => {
20
39
  expect(getISRCache()).toBeNull()
@@ -63,10 +82,16 @@ describe('isr-invalidation', () => {
63
82
 
64
83
  await purgeAllPages()
65
84
 
66
- expect((await cache.lookup('/')).status).toBe('miss')
67
- expect((await cache.lookup('/todos')).status).toBe('miss')
68
- expect((await cache.lookup('/content')).status).toBe('miss')
69
- expect((await cache.lookup('/content/123')).status).toBe('miss')
85
+ if (hasTodosModule) {
86
+ // '/''/todos' 路由由 todos 模块注册,仅含 todos 的 preset 可断言
87
+ expect((await cache.lookup('/')).status).toBe('miss')
88
+ expect((await cache.lookup('/todos')).status).toBe('miss')
89
+ }
90
+ if (hasContentModule) {
91
+ // content 相关页仅在 preset 含 content 模块时被注册、才可被清理
92
+ expect((await cache.lookup('/content')).status).toBe('miss')
93
+ expect((await cache.lookup('/content/123')).status).toBe('miss')
94
+ }
70
95
  })
71
96
  })
72
97
  })
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @framework-baseline 560bce7dbeafc84a
3
+ *
4
+ * @framework-modify
5
+ * @reason prettier 格式化导致内容哈希与初始基准漂移,补记说明
6
+ * @impact 仅格式,无逻辑改动
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from 'vitest'
10
+ import { createISRRegistry } from '@server/core/isr-registry'
11
+
12
+ describe('ISR Registry', () => {
13
+ let registry: ReturnType<typeof createISRRegistry>
14
+
15
+ beforeEach(() => {
16
+ registry = createISRRegistry()
17
+ })
18
+
19
+ describe('register', () => {
20
+ it('should register a single route', () => {
21
+ registry.register({
22
+ module: 'todos',
23
+ match: '/todos',
24
+ fetch: async () => ({}),
25
+ meta: () => ({ title: 'Todos', description: 'Todo list' }),
26
+ })
27
+ expect(registry.getAll()).toHaveLength(1)
28
+ })
29
+
30
+ it('should register multiple routes', () => {
31
+ registry.registerMany([
32
+ {
33
+ module: 'todos',
34
+ match: '/todos',
35
+ fetch: async () => ({}),
36
+ meta: () => ({ title: 'Todos', description: '' }),
37
+ },
38
+ {
39
+ module: 'content',
40
+ match: '/content',
41
+ fetch: async () => ({}),
42
+ meta: () => ({ title: 'Content', description: '' }),
43
+ },
44
+ ])
45
+ expect(registry.getAll()).toHaveLength(2)
46
+ })
47
+
48
+ it('should update existing entry on re-register', () => {
49
+ registry.register({
50
+ module: 'todos',
51
+ match: '/todos',
52
+ fetch: async () => ({}),
53
+ meta: () => ({ title: 'v1', description: '' }),
54
+ })
55
+ registry.register({
56
+ module: 'todos',
57
+ match: '/todos',
58
+ fetch: async () => ({}),
59
+ meta: () => ({ title: 'v2', description: '' }),
60
+ })
61
+ expect(registry.getAll()).toHaveLength(1)
62
+ expect(registry.getAll()[0].meta({}, '')).toEqual({ title: 'v2', description: '' })
63
+ })
64
+ })
65
+
66
+ describe('match — exact paths', () => {
67
+ it('should match exact path', () => {
68
+ registry.register({
69
+ module: 'todos',
70
+ match: '/todos',
71
+ fetch: async () => ({}),
72
+ meta: () => ({ title: '', description: '' }),
73
+ })
74
+ const entry = registry.match('/todos')
75
+ expect(entry).not.toBeNull()
76
+ expect(entry!.module).toBe('todos')
77
+ })
78
+
79
+ it('should not match wrong exact path', () => {
80
+ registry.register({
81
+ module: 'todos',
82
+ match: '/todos',
83
+ fetch: async () => ({}),
84
+ meta: () => ({ title: '', description: '' }),
85
+ })
86
+ expect(registry.match('/orders')).toBeNull()
87
+ })
88
+
89
+ it('should match root /', () => {
90
+ registry.register({
91
+ module: 'home',
92
+ match: '/',
93
+ fetch: async () => ({}),
94
+ meta: () => ({ title: '', description: '' }),
95
+ })
96
+ expect(registry.match('/')).not.toBeNull()
97
+ expect(registry.match('/todos')).toBeNull()
98
+ })
99
+ })
100
+
101
+ describe('match — prefix paths', () => {
102
+ it('should match prefix /content/', () => {
103
+ registry.register({
104
+ module: 'content',
105
+ match: '/content/',
106
+ fetch: async () => ({}),
107
+ meta: () => ({ title: '', description: '' }),
108
+ })
109
+ expect(registry.match('/content/content-1')).not.toBeNull()
110
+ expect(registry.match('/content/any-id')).not.toBeNull()
111
+ })
112
+
113
+ it('should not match prefix for unrelated path', () => {
114
+ registry.register({
115
+ module: 'content',
116
+ match: '/content/',
117
+ fetch: async () => ({}),
118
+ meta: () => ({ title: '', description: '' }),
119
+ })
120
+ expect(registry.match('/todos')).toBeNull()
121
+ })
122
+
123
+ it('should prefer exact match over prefix', () => {
124
+ registry.register({
125
+ module: 'content-detail',
126
+ match: '/content/',
127
+ fetch: async () => ({}),
128
+ meta: () => ({ title: 'detail', description: '' }),
129
+ })
130
+ registry.register({
131
+ module: 'content-list',
132
+ match: '/content',
133
+ fetch: async () => ({}),
134
+ meta: () => ({ title: 'list', description: '' }),
135
+ })
136
+ const exact = registry.match('/content')
137
+ const prefix = registry.match('/content/article-1')
138
+ expect(exact!.module).toBe('content-list')
139
+ expect(prefix!.module).toBe('content-detail')
140
+ })
141
+ })
142
+
143
+ describe('match — function matchers', () => {
144
+ it('should match via function', () => {
145
+ registry.register({
146
+ module: 'custom',
147
+ match: pathname => pathname.startsWith('/shop/'),
148
+ fetch: async () => ({}),
149
+ meta: () => ({ title: '', description: '' }),
150
+ })
151
+ expect(registry.match('/shop/product-1')).not.toBeNull()
152
+ expect(registry.match('/shop/')).not.toBeNull()
153
+ expect(registry.match('/other')).toBeNull()
154
+ })
155
+ })
156
+
157
+ describe('isISRRoute', () => {
158
+ it('should return true for registered routes', () => {
159
+ registry.register({
160
+ module: 'todos',
161
+ match: '/todos',
162
+ fetch: async () => ({}),
163
+ meta: () => ({ title: '', description: '' }),
164
+ })
165
+ expect(registry.isISRRoute('/todos')).toBe(true)
166
+ })
167
+
168
+ it('should return false for unregistered routes', () => {
169
+ expect(registry.isISRRoute('/unknown')).toBe(false)
170
+ })
171
+ })
172
+
173
+ describe('getExactPaths', () => {
174
+ it('should return all exact path strings', () => {
175
+ registry.registerMany([
176
+ {
177
+ module: 'a',
178
+ match: '/a',
179
+ fetch: async () => ({}),
180
+ meta: () => ({ title: '', description: '' }),
181
+ },
182
+ {
183
+ module: 'b',
184
+ match: '/b/',
185
+ fetch: async () => ({}),
186
+ meta: () => ({ title: '', description: '' }),
187
+ },
188
+ {
189
+ module: 'c',
190
+ match: '/c',
191
+ fetch: async () => ({}),
192
+ meta: () => ({ title: '', description: '' }),
193
+ },
194
+ ])
195
+ const paths = registry.getExactPaths()
196
+ expect(paths).toContain('/a')
197
+ expect(paths).toContain('/b/')
198
+ expect(paths).toContain('/c')
199
+ expect(paths).toHaveLength(3)
200
+ })
201
+ })
202
+
203
+ describe('clear', () => {
204
+ it('should remove all entries', () => {
205
+ registry.register({
206
+ module: 'a',
207
+ match: '/a',
208
+ fetch: async () => ({}),
209
+ meta: () => ({ title: '', description: '' }),
210
+ })
211
+ registry.clear()
212
+ expect(registry.getAll()).toHaveLength(0)
213
+ })
214
+ })
215
+ })