create-fullstack-scaffold 0.1.1 → 0.2.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/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import { successResponse } from '@server/utils/route-helpers'
|
|
4
|
+
import { ECommerceOrderListSchema } from '@shared/schemas'
|
|
5
|
+
|
|
6
|
+
const MOCK_ORDERS = [
|
|
7
|
+
{
|
|
8
|
+
id: 'ORD-2024-001',
|
|
9
|
+
date: '2024-12-10',
|
|
10
|
+
status: 'processing' as const,
|
|
11
|
+
products: [
|
|
12
|
+
{ name: 'Wireless Headphones', color: '#374151' },
|
|
13
|
+
{ name: 'USB-C Cable', color: '#6366f1' },
|
|
14
|
+
],
|
|
15
|
+
total: 94.98,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'ORD-2024-002',
|
|
19
|
+
date: '2024-12-08',
|
|
20
|
+
status: 'shipped' as const,
|
|
21
|
+
products: [
|
|
22
|
+
{ name: 'Organic Cotton T-Shirt', color: '#6ee7b7' },
|
|
23
|
+
{ name: 'Ceramic Travel Mug', color: '#f59e0b' },
|
|
24
|
+
{ name: 'Linen Tote Bag', color: '#d4a574' },
|
|
25
|
+
],
|
|
26
|
+
total: 79.97,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'ORD-2024-003',
|
|
30
|
+
date: '2024-12-01',
|
|
31
|
+
status: 'delivered' as const,
|
|
32
|
+
products: [
|
|
33
|
+
{ name: 'Bamboo Desk Organizer', color: '#a3e635' },
|
|
34
|
+
{ name: 'Recycled Notebook', color: '#f472b6' },
|
|
35
|
+
],
|
|
36
|
+
total: 45.98,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'ORD-2024-004',
|
|
40
|
+
date: '2024-11-25',
|
|
41
|
+
status: 'delivered' as const,
|
|
42
|
+
products: [{ name: 'Beeswax Candle Set', color: '#fbbf24' }],
|
|
43
|
+
total: 29.99,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'ORD-2024-005',
|
|
47
|
+
date: '2024-11-20',
|
|
48
|
+
status: 'cancelled' as const,
|
|
49
|
+
products: [
|
|
50
|
+
{ name: 'Wooden Phone Stand', color: '#92400e' },
|
|
51
|
+
{ name: 'Plant-Based Soap', color: '#86efac' },
|
|
52
|
+
],
|
|
53
|
+
total: 39.98,
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const getOrdersRoute = createRoute({
|
|
58
|
+
method: 'get',
|
|
59
|
+
path: '/orders-mock',
|
|
60
|
+
responses: {
|
|
61
|
+
200: successResponse(ECommerceOrderListSchema, 'List e-commerce orders'),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const ordersMockRoutes = new OpenAPIHono().openapi(getOrdersRoute, async c => {
|
|
66
|
+
return c.json({ success: true as const, data: MOCK_ORDERS })
|
|
67
|
+
})
|
|
@@ -8,7 +8,7 @@ import { parseModuleId } from '@server/utils/id-helpers'
|
|
|
8
8
|
|
|
9
9
|
export async function seedOrdersIfEmpty(): Promise<void> {
|
|
10
10
|
const db = await getDb()
|
|
11
|
-
const existing = await db.select().from(orders)
|
|
11
|
+
const existing = await db.select().from(orders)
|
|
12
12
|
if (existing.length === 0) {
|
|
13
13
|
const STATUSES: OrderStatus[] = ['pending', 'processing', 'completed', 'cancelled', 'disputed']
|
|
14
14
|
const CUSTOMERS = [
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import * as queryService from '../services/plugin-query-service'
|
|
3
|
+
import { getRawClient, getDb } from '@server/db'
|
|
4
|
+
import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
|
|
5
|
+
import { NotFoundError } 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_reviews')
|
|
11
|
+
await client.execute('DELETE FROM plugin_versions')
|
|
12
|
+
await client.execute('DELETE FROM plugin_category_mappings')
|
|
13
|
+
await client.execute('DELETE FROM plugins')
|
|
14
|
+
await client.execute('DELETE FROM plugin_categories')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function insertPlugin(overrides: Record<string, unknown> = {}) {
|
|
19
|
+
const client = await getRawClient()
|
|
20
|
+
if (!client || !('execute' in client)) throw new Error('No DB client')
|
|
21
|
+
|
|
22
|
+
const id = (overrides.id ??
|
|
23
|
+
`plugin-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`) as string
|
|
24
|
+
const now = Date.now()
|
|
25
|
+
await client.execute({
|
|
26
|
+
sql: `INSERT INTO plugins (id, name, slug, description, author_id, author_name, version, status, download_count, view_count, featured, created_at, updated_at, tags)
|
|
27
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
28
|
+
args: [
|
|
29
|
+
id as string,
|
|
30
|
+
(overrides.name ?? 'Test Plugin') as string,
|
|
31
|
+
(overrides.slug ?? id) as string,
|
|
32
|
+
(overrides.description ?? 'A test plugin') as string,
|
|
33
|
+
(overrides.authorId ?? 'user-1') as string,
|
|
34
|
+
(overrides.authorName ?? 'Test User') as string,
|
|
35
|
+
(overrides.version ?? '0.0.1') as string,
|
|
36
|
+
(overrides.status ?? 'approved') as string,
|
|
37
|
+
(overrides.downloadCount ?? 0) as number,
|
|
38
|
+
(overrides.viewCount ?? 0) as number,
|
|
39
|
+
(overrides.featured === true ? 1 : 0) as number,
|
|
40
|
+
(overrides.createdAt ?? now) as number,
|
|
41
|
+
(overrides.updatedAt ?? now) as number,
|
|
42
|
+
(overrides.tags ?? null) as string | null,
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
return id
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('Plugin Query Service', () => {
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
await setupTestDatabase()
|
|
51
|
+
const db = await getDb()
|
|
52
|
+
expect(db).toBeDefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await cleanupTestDatabase()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
await clearPluginTables()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
await clearPluginTables()
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('listPlugins', () => {
|
|
69
|
+
it('should return empty list when no approved plugins exist', async () => {
|
|
70
|
+
const result = await queryService.listPlugins()
|
|
71
|
+
expect(result.plugins).toEqual([])
|
|
72
|
+
expect(result.total).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should return approved plugins with pagination', async () => {
|
|
76
|
+
await insertPlugin({ slug: 'plugin-a', status: 'approved' })
|
|
77
|
+
await insertPlugin({ slug: 'plugin-b', status: 'approved' })
|
|
78
|
+
await insertPlugin({ slug: 'plugin-c', status: 'pending' })
|
|
79
|
+
|
|
80
|
+
const result = await queryService.listPlugins({ page: 1, limit: 10 })
|
|
81
|
+
|
|
82
|
+
expect(result.plugins).toHaveLength(2)
|
|
83
|
+
expect(result.total).toBe(2)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should filter by status', async () => {
|
|
87
|
+
await insertPlugin({ slug: 'pending-1', status: 'pending' })
|
|
88
|
+
await insertPlugin({ slug: 'approved-1', status: 'approved' })
|
|
89
|
+
|
|
90
|
+
const result = await queryService.listPlugins({ status: 'pending' })
|
|
91
|
+
|
|
92
|
+
expect(result.plugins).toHaveLength(1)
|
|
93
|
+
expect(result.plugins[0].status).toBe('pending')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should filter by featured', async () => {
|
|
97
|
+
await insertPlugin({ slug: 'featured-1', status: 'approved', featured: true })
|
|
98
|
+
await insertPlugin({ slug: 'regular-1', status: 'approved', featured: false })
|
|
99
|
+
|
|
100
|
+
const result = await queryService.listPlugins({ featured: true })
|
|
101
|
+
|
|
102
|
+
expect(result.plugins).toHaveLength(1)
|
|
103
|
+
expect(result.plugins[0].featured).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should paginate correctly', async () => {
|
|
107
|
+
for (let i = 0; i < 5; i++) {
|
|
108
|
+
await insertPlugin({ slug: `page-plugin-${i}`, status: 'approved' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const page1 = await queryService.listPlugins({ page: 1, limit: 2 })
|
|
112
|
+
const page2 = await queryService.listPlugins({ page: 2, limit: 2 })
|
|
113
|
+
|
|
114
|
+
expect(page1.plugins).toHaveLength(2)
|
|
115
|
+
expect(page1.page).toBe(1)
|
|
116
|
+
expect(page1.total).toBe(5)
|
|
117
|
+
expect(page2.plugins).toHaveLength(2)
|
|
118
|
+
expect(page2.page).toBe(2)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('searchPlugins', () => {
|
|
123
|
+
it('should search by name', async () => {
|
|
124
|
+
await insertPlugin({ name: 'React Utils', slug: 'react-utils', status: 'approved' })
|
|
125
|
+
await insertPlugin({ name: 'Vue Helper', slug: 'vue-helper', status: 'approved' })
|
|
126
|
+
|
|
127
|
+
const result = await queryService.searchPlugins('React')
|
|
128
|
+
|
|
129
|
+
expect(result.plugins).toHaveLength(1)
|
|
130
|
+
expect(result.plugins[0].name).toBe('React Utils')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should search by description', async () => {
|
|
134
|
+
await insertPlugin({
|
|
135
|
+
name: 'Tool',
|
|
136
|
+
slug: 'search-tool',
|
|
137
|
+
description: 'A React component library',
|
|
138
|
+
status: 'approved',
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const result = await queryService.searchPlugins('component')
|
|
142
|
+
|
|
143
|
+
expect(result.plugins).toHaveLength(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should return empty for no matches', async () => {
|
|
147
|
+
await insertPlugin({ slug: 'existing-plugin', status: 'approved' })
|
|
148
|
+
|
|
149
|
+
const result = await queryService.searchPlugins('nonexistent-xyz')
|
|
150
|
+
|
|
151
|
+
expect(result.plugins).toHaveLength(0)
|
|
152
|
+
expect(result.total).toBe(0)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('getPluginBySlug', () => {
|
|
157
|
+
it('should return plugin and increment view count', async () => {
|
|
158
|
+
await insertPlugin({ slug: 'viewed-plugin', viewCount: 0 })
|
|
159
|
+
|
|
160
|
+
const plugin = await queryService.getPluginBySlug('viewed-plugin')
|
|
161
|
+
|
|
162
|
+
expect(plugin.slug).toBe('viewed-plugin')
|
|
163
|
+
expect(plugin.viewCount).toBe(0)
|
|
164
|
+
|
|
165
|
+
const plugin2 = await queryService.getPluginBySlug('viewed-plugin')
|
|
166
|
+
expect(plugin2.viewCount).toBe(1)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should throw NotFoundError for non-existent slug', async () => {
|
|
170
|
+
await expect(queryService.getPluginBySlug('non-existent')).rejects.toThrow(NotFoundError)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('getVersions', () => {
|
|
175
|
+
it('should return empty array for plugin with no versions', async () => {
|
|
176
|
+
const pluginId: string = await insertPlugin({ slug: 'no-versions' })
|
|
177
|
+
|
|
178
|
+
const versions = await queryService.getVersions(pluginId)
|
|
179
|
+
|
|
180
|
+
expect(versions).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('listCategories', () => {
|
|
185
|
+
it('should return empty when no categories exist', async () => {
|
|
186
|
+
const categories = await queryService.listCategories()
|
|
187
|
+
expect(categories).toEqual([])
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('getStats', () => {
|
|
192
|
+
it('should return marketplace stats', async () => {
|
|
193
|
+
await insertPlugin({ slug: 'stats-1', status: 'approved', downloadCount: 10 })
|
|
194
|
+
await insertPlugin({ slug: 'stats-2', status: 'approved', downloadCount: 5 })
|
|
195
|
+
|
|
196
|
+
const stats = await queryService.getStats()
|
|
197
|
+
|
|
198
|
+
expect(stats.totalPlugins).toBe(2)
|
|
199
|
+
expect(stats.totalDownloads).toBe(15)
|
|
200
|
+
expect(stats.totalDevelopers).toBeGreaterThanOrEqual(1)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import * as pluginService from '../services/plugin-service'
|
|
3
|
+
import { getRawClient, getDb } from '@server/db'
|
|
4
|
+
import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
|
|
5
|
+
import { NotFoundError, AuthorizationError, 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_reviews')
|
|
11
|
+
await client.execute('DELETE FROM plugin_versions')
|
|
12
|
+
await client.execute('DELETE FROM plugin_category_mappings')
|
|
13
|
+
await client.execute('DELETE FROM plugins')
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function insertTestPlugin(overrides: Record<string, unknown> = {}) {
|
|
18
|
+
const client = await getRawClient()
|
|
19
|
+
if (!client || !('execute' in client)) throw new Error('No DB client')
|
|
20
|
+
|
|
21
|
+
const id = overrides.id ?? 'test-plugin-id'
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
await client.execute({
|
|
24
|
+
sql: `INSERT INTO plugins (id, name, slug, description, author_id, author_name, version, status, download_count, view_count, featured, created_at, updated_at, tags, site_urls, commands)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
26
|
+
args: [
|
|
27
|
+
id as string,
|
|
28
|
+
(overrides.name ?? 'Test Plugin') as string,
|
|
29
|
+
(overrides.slug ?? 'test-plugin') as string,
|
|
30
|
+
(overrides.description ?? 'A test plugin') as string,
|
|
31
|
+
(overrides.authorId ?? 'user-1') as string,
|
|
32
|
+
(overrides.authorName ?? 'Test User') as string,
|
|
33
|
+
(overrides.version ?? '0.0.1') as string,
|
|
34
|
+
(overrides.status ?? 'pending') as string,
|
|
35
|
+
(overrides.downloadCount ?? 0) as number,
|
|
36
|
+
(overrides.viewCount ?? 0) as number,
|
|
37
|
+
(overrides.featured === true ? 1 : 0) as number,
|
|
38
|
+
(overrides.createdAt ?? now) as number,
|
|
39
|
+
(overrides.updatedAt ?? now) as number,
|
|
40
|
+
(overrides.tags ?? null) as string | null,
|
|
41
|
+
(overrides.siteUrls ?? null) as string | null,
|
|
42
|
+
(overrides.commands ?? null) as string | null,
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
return id
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('Plugin Service', () => {
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
await setupTestDatabase()
|
|
51
|
+
const db = await getDb()
|
|
52
|
+
expect(db).toBeDefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await cleanupTestDatabase()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
await clearPluginTables()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
await clearPluginTables()
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('createPlugin', () => {
|
|
69
|
+
it('should create a plugin with status pending', async () => {
|
|
70
|
+
const result = await pluginService.createPlugin({
|
|
71
|
+
name: 'My Plugin',
|
|
72
|
+
slug: 'my-plugin',
|
|
73
|
+
description: 'A great plugin',
|
|
74
|
+
authorId: 'user-1',
|
|
75
|
+
authorName: 'Developer',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(result.id).toBeDefined()
|
|
79
|
+
expect(result.name).toBe('My Plugin')
|
|
80
|
+
expect(result.slug).toBe('my-plugin')
|
|
81
|
+
expect(result.status).toBe('pending')
|
|
82
|
+
expect(result.version).toBe('0.0.1')
|
|
83
|
+
expect(result.downloadCount).toBe(0)
|
|
84
|
+
expect(result.viewCount).toBe(0)
|
|
85
|
+
expect(result.featured).toBe(false)
|
|
86
|
+
expect(result.authorId).toBe('user-1')
|
|
87
|
+
expect(result.authorName).toBe('Developer')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should create a plugin with optional fields', async () => {
|
|
91
|
+
const result = await pluginService.createPlugin({
|
|
92
|
+
name: 'Full Plugin',
|
|
93
|
+
slug: 'full-plugin',
|
|
94
|
+
description: 'Plugin with all fields',
|
|
95
|
+
authorId: 'user-1',
|
|
96
|
+
authorName: 'Developer',
|
|
97
|
+
repositoryUrl: 'https://github.com/test/plugin',
|
|
98
|
+
homepageUrl: 'https://plugin.dev',
|
|
99
|
+
npmPackage: '@test/plugin',
|
|
100
|
+
license: 'MIT',
|
|
101
|
+
tags: ['utility', 'tools'],
|
|
102
|
+
siteUrls: ['https://example.com'],
|
|
103
|
+
commands: [{ name: 'init', description: 'Initialize' }],
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(result.repositoryUrl).toBe('https://github.com/test/plugin')
|
|
107
|
+
expect(result.homepageUrl).toBe('https://plugin.dev')
|
|
108
|
+
expect(result.npmPackage).toBe('@test/plugin')
|
|
109
|
+
expect(result.license).toBe('MIT')
|
|
110
|
+
expect(result.tags).toEqual(['utility', 'tools'])
|
|
111
|
+
expect(result.siteUrls).toEqual(['https://example.com'])
|
|
112
|
+
expect(result.commands).toEqual([{ name: 'init', description: 'Initialize' }])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should throw ConflictError for duplicate slug', async () => {
|
|
116
|
+
await pluginService.createPlugin({
|
|
117
|
+
name: 'Plugin 1',
|
|
118
|
+
slug: 'duplicate-slug',
|
|
119
|
+
description: 'First',
|
|
120
|
+
authorId: 'user-1',
|
|
121
|
+
authorName: 'Dev',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
pluginService.createPlugin({
|
|
126
|
+
name: 'Plugin 2',
|
|
127
|
+
slug: 'duplicate-slug',
|
|
128
|
+
description: 'Second',
|
|
129
|
+
authorId: 'user-2',
|
|
130
|
+
authorName: 'Dev2',
|
|
131
|
+
})
|
|
132
|
+
).rejects.toThrow(ConflictError)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('updatePlugin', () => {
|
|
137
|
+
it('should update plugin fields', async () => {
|
|
138
|
+
await insertTestPlugin({ slug: 'updatable-plugin', authorId: 'user-1' })
|
|
139
|
+
|
|
140
|
+
const result = await pluginService.updatePlugin(
|
|
141
|
+
'updatable-plugin',
|
|
142
|
+
{
|
|
143
|
+
name: 'Updated Name',
|
|
144
|
+
description: 'Updated desc',
|
|
145
|
+
},
|
|
146
|
+
'user-1'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(result.name).toBe('Updated Name')
|
|
150
|
+
expect(result.description).toBe('Updated desc')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should throw NotFoundError for non-existent slug', async () => {
|
|
154
|
+
await expect(
|
|
155
|
+
pluginService.updatePlugin('non-existent', { name: 'X' }, 'user-1')
|
|
156
|
+
).rejects.toThrow(NotFoundError)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should throw AuthorizationError for non-author', async () => {
|
|
160
|
+
await insertTestPlugin({ slug: 'auth-plugin', authorId: 'user-1' })
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
pluginService.updatePlugin('auth-plugin', { name: 'X' }, 'user-2')
|
|
164
|
+
).rejects.toThrow(AuthorizationError)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should update JSON fields', async () => {
|
|
168
|
+
await insertTestPlugin({ slug: 'json-plugin', authorId: 'user-1' })
|
|
169
|
+
|
|
170
|
+
const result = await pluginService.updatePlugin(
|
|
171
|
+
'json-plugin',
|
|
172
|
+
{
|
|
173
|
+
tags: ['new-tag'],
|
|
174
|
+
siteUrls: ['https://new-site.com'],
|
|
175
|
+
},
|
|
176
|
+
'user-1'
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(result.tags).toEqual(['new-tag'])
|
|
180
|
+
expect(result.siteUrls).toEqual(['https://new-site.com'])
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('deletePlugin', () => {
|
|
185
|
+
it('should delete a plugin', async () => {
|
|
186
|
+
await insertTestPlugin({ slug: 'deletable-plugin', authorId: 'user-1' })
|
|
187
|
+
|
|
188
|
+
await pluginService.deletePlugin('deletable-plugin', 'user-1')
|
|
189
|
+
|
|
190
|
+
const client = await getRawClient()
|
|
191
|
+
if (client && 'execute' in client) {
|
|
192
|
+
const result = await client.execute('SELECT * FROM plugins WHERE slug = ?', [
|
|
193
|
+
'deletable-plugin',
|
|
194
|
+
])
|
|
195
|
+
expect(result.rows.length).toBe(0)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should throw NotFoundError for non-existent slug', async () => {
|
|
200
|
+
await expect(pluginService.deletePlugin('non-existent', 'user-1')).rejects.toThrow(
|
|
201
|
+
NotFoundError
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should throw AuthorizationError for non-author', async () => {
|
|
206
|
+
await insertTestPlugin({ slug: 'auth-delete-plugin', authorId: 'user-1' })
|
|
207
|
+
|
|
208
|
+
await expect(pluginService.deletePlugin('auth-delete-plugin', 'user-2')).rejects.toThrow(
|
|
209
|
+
AuthorizationError
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('trackInstall', () => {
|
|
215
|
+
it('should increment download count', async () => {
|
|
216
|
+
await insertTestPlugin({ slug: 'install-plugin', downloadCount: 5 })
|
|
217
|
+
|
|
218
|
+
await pluginService.trackInstall('install-plugin')
|
|
219
|
+
|
|
220
|
+
const client = await getRawClient()
|
|
221
|
+
if (client && 'execute' in client) {
|
|
222
|
+
const result = await client.execute('SELECT download_count FROM plugins WHERE slug = ?', [
|
|
223
|
+
'install-plugin',
|
|
224
|
+
])
|
|
225
|
+
const row = result.rows[0] as unknown as { download_count: number }
|
|
226
|
+
expect(row.download_count).toBe(6)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should throw NotFoundError for non-existent slug', async () => {
|
|
231
|
+
await expect(pluginService.trackInstall('non-existent')).rejects.toThrow(NotFoundError)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ModuleManifest } from '@shared/core/module-manifest'
|
|
2
|
+
|
|
3
|
+
const pluginManifest: ModuleManifest = {
|
|
4
|
+
name: 'plugin',
|
|
5
|
+
description:
|
|
6
|
+
'Plugin marketplace with CRUD, publishing, reviews, categories, and admin management',
|
|
7
|
+
category: 'business',
|
|
8
|
+
dependsOn: ['permission'],
|
|
9
|
+
|
|
10
|
+
routes: {
|
|
11
|
+
client: {
|
|
12
|
+
importPath: './routes/plugin-routes',
|
|
13
|
+
exportName: 'pluginRoutes',
|
|
14
|
+
},
|
|
15
|
+
admin: [
|
|
16
|
+
{
|
|
17
|
+
importPath: './routes/plugin-admin-routes',
|
|
18
|
+
exportName: 'pluginAdminRoutes',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
sharedSchemas: {
|
|
24
|
+
path: 'plugins',
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
clientPages: [
|
|
28
|
+
{ name: 'PluginsPage', route: '/plugins' },
|
|
29
|
+
{ name: 'PluginDetailPage', route: '/plugins/:slug' },
|
|
30
|
+
{ name: 'CategoriesPage', route: '/categories' },
|
|
31
|
+
{ name: 'SearchPage', route: '/search' },
|
|
32
|
+
{ name: 'PublishPage', route: '/publish' },
|
|
33
|
+
{ name: 'DeveloperDashboardPage', route: '/developer' },
|
|
34
|
+
{ name: 'TopicsPage', route: '/topics' },
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
adminPages: [
|
|
38
|
+
{ name: 'PluginManagementPage', route: '/admin/plugins' },
|
|
39
|
+
{ name: 'PluginReviewPage', route: '/admin/plugins/review' },
|
|
40
|
+
{ name: 'CategoryManagementPage', route: '/admin/categories' },
|
|
41
|
+
{ name: 'PluginDashboardPage', route: '/admin/plugins/dashboard' },
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
clientStores: ['pluginStore'],
|
|
45
|
+
|
|
46
|
+
dbSchemas: {
|
|
47
|
+
files: ['plugins'],
|
|
48
|
+
hasSeed: true,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default pluginManifest
|