create-fullstack-scaffold 0.4.9 → 0.4.11
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 +499 -100
- package/dist/cli/index.js.map +1 -1
- package/package.json +2 -2
- package/template/CLAUDE.md +18 -6
- package/template/drizzle/0004_add_merchants_products.sql +30 -0
- package/template/drizzle/meta/_journal.json +7 -0
- package/template/eslint-rules/module-boundary.js +6 -2
- package/template/eslint-rules/no-cross-module-service-import.js +105 -0
- package/template/eslint-rules/no-disable-type-safe-client.js +5 -0
- package/template/eslint.config.js +9 -0
- package/template/lint-scripts/config/project.config.ts +21 -0
- package/template/lint-scripts/validate-all.ts +48 -12
- package/template/lint-scripts/validators/index.ts +29 -0
- package/template/lint-scripts/validators/module-public-api.validator.ts +103 -0
- package/template/lint-scripts/validators/schema-uniqueness.validator.ts +147 -0
- package/template/modules.config.ts +2 -0
- package/template/package.json +2 -0
- package/template/playwright.config.ts +14 -0
- package/template/src/admin/App.tsx +40 -3
- package/template/src/admin/components/LanguageSwitcher.tsx +22 -0
- package/template/src/admin/components/NotificationDrawer.tsx +108 -38
- package/template/src/admin/components/StatsCard.tsx +3 -1
- package/template/src/admin/components/ThemeToggle.tsx +28 -0
- package/template/src/admin/hooks/useRoles.ts +2 -2
- package/template/src/admin/i18n/index.ts +18 -0
- package/template/src/admin/i18n/locales/en-US.json +381 -0
- package/template/src/admin/i18n/locales/zh-CN.json +381 -0
- package/template/src/admin/i18n/useLanguage.ts +21 -0
- package/template/src/admin/layouts/Header.tsx +37 -16
- package/template/src/admin/layouts/Layout.tsx +7 -3
- package/template/src/admin/layouts/Sidebar.tsx +70 -68
- package/template/src/admin/main.tsx +1 -0
- package/template/src/admin/pages/ContentPage.tsx +68 -56
- package/template/src/admin/pages/DashboardPage.tsx +82 -76
- package/template/src/admin/pages/DisputesPage.tsx +61 -53
- package/template/src/admin/pages/LoginPage.tsx +41 -33
- package/template/src/admin/pages/OrdersPage.tsx +69 -64
- package/template/src/admin/pages/PermissionsPage.tsx +10 -8
- package/template/src/admin/pages/PluginDashboardPage.tsx +3 -1
- package/template/src/admin/pages/PluginManagementPage.tsx +3 -1
- package/template/src/admin/pages/RegisterPage.tsx +18 -16
- package/template/src/admin/pages/RolesPage.tsx +51 -49
- package/template/src/admin/pages/SettingsPage.tsx +22 -22
- package/template/src/admin/pages/SystemLogsPage.tsx +32 -30
- package/template/src/admin/pages/TicketsPage.tsx +67 -59
- package/template/src/admin/pages/UsersPage.tsx +63 -53
- package/template/src/admin/pages/__tests__/RolesPage.test.tsx +2 -2
- package/template/src/admin/stores/themeStore.ts +32 -0
- package/template/src/cli/modules/admin/index.ts +200 -0
- package/template/src/cli/modules/captcha/index.ts +40 -0
- package/template/src/cli/modules/chat/index.ts +21 -0
- package/template/src/cli/modules/content/index.ts +144 -0
- package/template/src/cli/modules/dispute/index.ts +150 -0
- package/template/src/cli/modules/file/index.ts +55 -0
- package/template/src/cli/modules/index.ts +30 -5
- package/template/src/cli/modules/order/index.ts +170 -0
- package/template/src/cli/modules/permission/index.ts +195 -0
- package/template/src/cli/modules/tenant/index.ts +142 -0
- package/template/src/cli/modules/ticket/index.ts +167 -0
- package/template/src/cli/rpc/client.ts +2 -2
- package/template/src/client/index.css +73 -0
- package/template/src/merchant/App.tsx +2 -0
- package/template/src/merchant/components/MerchantGuard.tsx +2 -6
- package/template/src/merchant/components/__tests__/MerchantGuard.test.tsx +117 -0
- package/template/src/merchant/layouts/Header.tsx +2 -2
- package/template/src/merchant/pages/DashboardPage.tsx +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +14 -10
- package/template/src/merchant/pages/LoginPage.tsx +49 -0
- package/template/src/merchant/pages/OrdersPage.tsx +17 -10
- package/template/src/merchant/pages/ProductsPage.tsx +31 -8
- package/template/src/merchant/pages/SettingsPage.tsx +3 -7
- package/template/src/merchant/stores/__tests__/merchantStore.test.ts +317 -0
- package/template/src/merchant/stores/merchantStore.ts +24 -27
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/merchants.ts +26 -0
- package/template/src/server/db/schema/products.ts +25 -0
- package/template/src/server/db/test-setup.ts +237 -0
- package/template/src/server/middleware/__tests__/audit-log.test.ts +144 -0
- package/template/src/server/middleware/__tests__/cors.test.ts +84 -0
- package/template/src/server/middleware/__tests__/logger.test.ts +117 -0
- package/template/src/server/middleware/__tests__/rate-limit.test.ts +73 -0
- package/template/src/server/middleware/__tests__/realtime-env.test.ts +56 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +150 -0
- package/template/src/server/module-admin/__tests__/admin-routes.test.ts +1 -1
- package/template/src/server/module-admin/module.ts +4 -0
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +3 -3
- package/template/src/server/module-admin/routes/user-management-routes.ts +2 -2
- package/template/src/server/module-auth/__tests__/auth-routes.test.ts +315 -0
- package/template/src/server/module-auth/__tests__/profile-routes.test.ts +83 -0
- package/template/src/server/module-auth/module.ts +2 -0
- package/template/src/server/module-content/module.ts +1 -0
- package/template/src/server/module-content/routes/content-routes.ts +2 -2
- package/template/src/server/module-dispute/module.ts +1 -0
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -2
- package/template/src/server/module-merchant/__tests__/merchant-routes.test.ts +218 -0
- package/template/src/server/module-merchant/__tests__/merchant-service.test.ts +183 -0
- package/template/src/server/module-merchant/index.ts +10 -0
- package/template/src/server/module-merchant/module.ts +33 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +138 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +292 -0
- package/template/src/server/module-notifications/index.ts +18 -0
- package/template/src/server/module-notifications/module.ts +2 -0
- package/template/src/server/module-order/module.ts +1 -0
- package/template/src/server/module-order/routes/order-routes.ts +2 -2
- package/template/src/server/module-permission/routes/role-routes.ts +3 -3
- package/template/src/server/module-plugin/__tests__/admin-category-service.test.ts +181 -0
- package/template/src/server/module-plugin/__tests__/admin-plugin-service.test.ts +277 -0
- package/template/src/server/module-plugin/__tests__/admin-stats-service.test.ts +170 -0
- package/template/src/server/module-plugin/__tests__/plugin-review-service.test.ts +260 -0
- package/template/src/server/module-plugin/__tests__/plugin-seed-service.test.ts +170 -0
- package/template/src/server/module-plugin/module.ts +3 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +142 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +152 -0
- package/template/src/server/module-tenant/module.ts +1 -0
- package/template/src/server/module-tenant/services/tenant-service.ts +1 -37
- package/template/src/server/module-ticket/module.ts +1 -0
- package/template/src/server/module-ticket/routes/ticket-routes.ts +2 -2
- package/template/src/server/module-todos/module.ts +3 -0
- package/template/src/server/route-registry.ts +4 -0
- package/template/src/server/utils/__tests__/date.test.ts +130 -0
- package/template/src/server/utils/__tests__/env.test.ts +25 -0
- package/template/src/server/utils/__tests__/generate.test.ts +82 -0
- package/template/src/server/utils/__tests__/id-helpers.test.ts +29 -0
- package/template/src/server/utils/__tests__/json.test.ts +49 -0
- package/template/src/server/utils/__tests__/permission-utils.test.ts +26 -0
- package/template/src/server/utils/__tests__/uuid.test.ts +25 -0
- package/template/src/shared/core/module-manifest.ts +15 -0
- package/template/src/shared/modules/admin/schemas.ts +1 -1
- package/template/src/shared/modules/content/schemas.ts +2 -2
- package/template/src/shared/modules/dispute/schemas.ts +2 -2
- package/template/src/shared/modules/index.ts +181 -1
- package/template/src/shared/modules/merchant/index.ts +12 -0
- package/template/src/shared/modules/merchant/schemas.ts +63 -0
- package/template/src/shared/modules/order/schemas.ts +2 -2
- package/template/src/shared/modules/permission/index.ts +1 -1
- package/template/src/shared/modules/permission/schemas.ts +1 -1
- package/template/src/shared/modules/role/schemas.ts +2 -2
- package/template/src/shared/modules/ticket/schemas.ts +2 -2
- package/template/src/shared/schemas/index.ts +74 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
|
|
4
|
+
vi.mock('@server/core', () => ({
|
|
5
|
+
setRealtimeEnv: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import { realtimeEnvMiddleware } from '../realtime-env'
|
|
9
|
+
import { setRealtimeEnv } from '@server/core'
|
|
10
|
+
|
|
11
|
+
describe('realtimeEnvMiddleware', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should call setRealtimeEnv with env when REALTIME_DO exists', async () => {
|
|
17
|
+
const app = new Hono()
|
|
18
|
+
app.use('*', realtimeEnvMiddleware())
|
|
19
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
20
|
+
|
|
21
|
+
const realtimeDo = { idFromName: vi.fn(), get: vi.fn() }
|
|
22
|
+
const res = await app.request('/test', {}, { REALTIME_DO: realtimeDo } as never)
|
|
23
|
+
expect(res.status).toBe(200)
|
|
24
|
+
expect(setRealtimeEnv).toHaveBeenCalledWith({ REALTIME_DO: realtimeDo })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should not call setRealtimeEnv when no env is present', async () => {
|
|
28
|
+
const app = new Hono()
|
|
29
|
+
app.use('*', realtimeEnvMiddleware())
|
|
30
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
31
|
+
|
|
32
|
+
const res = await app.request('/test')
|
|
33
|
+
expect(res.status).toBe(200)
|
|
34
|
+
expect(setRealtimeEnv).not.toHaveBeenCalled()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should call setRealtimeEnv even when env has no REALTIME_DO', async () => {
|
|
38
|
+
const app = new Hono()
|
|
39
|
+
app.use('*', realtimeEnvMiddleware())
|
|
40
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
41
|
+
|
|
42
|
+
const res = await app.request('/test', {}, {} as never)
|
|
43
|
+
expect(res.status).toBe(200)
|
|
44
|
+
expect(setRealtimeEnv).toHaveBeenCalledWith({})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should pass through to next handler', async () => {
|
|
48
|
+
const app = new Hono()
|
|
49
|
+
app.use('*', realtimeEnvMiddleware())
|
|
50
|
+
app.get('/test', c => c.json({ ok: true }))
|
|
51
|
+
|
|
52
|
+
const res = await app.request('/test')
|
|
53
|
+
const json = (await res.json()) as { ok: boolean }
|
|
54
|
+
expect(json.ok).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { AppError } from '@server/utils/app-error'
|
|
4
|
+
|
|
5
|
+
vi.mock('@server/db', () => ({
|
|
6
|
+
getDb: vi.fn().mockResolvedValue({
|
|
7
|
+
select: vi.fn().mockReturnThis(),
|
|
8
|
+
from: vi.fn().mockReturnThis(),
|
|
9
|
+
where: vi.fn().mockResolvedValue([]),
|
|
10
|
+
}),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('@server/db/schema', () => ({
|
|
14
|
+
tenants: { slug: 'slug' },
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('drizzle-orm', () => ({
|
|
18
|
+
eq: vi.fn((_col, val) => val),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
vi.mock('@server/utils/logger', () => ({
|
|
22
|
+
createModuleLoggerSync: () => ({
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
error: vi.fn(),
|
|
26
|
+
}),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
import { tenantIsolationMiddleware } from '../tenant-isolation'
|
|
30
|
+
import { getDb } from '@server/db'
|
|
31
|
+
|
|
32
|
+
describe('tenantIsolationMiddleware', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function mockDbResult(rows: unknown[]) {
|
|
38
|
+
vi.mocked(getDb).mockResolvedValueOnce({
|
|
39
|
+
select: vi.fn().mockReturnThis(),
|
|
40
|
+
from: vi.fn().mockReturnThis(),
|
|
41
|
+
where: vi.fn().mockResolvedValue(rows),
|
|
42
|
+
} as never)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createApp() {
|
|
46
|
+
const app = new Hono()
|
|
47
|
+
app.onError((err, c) => {
|
|
48
|
+
if (AppError.isAppError(err)) {
|
|
49
|
+
return c.json({ success: false, error: err.message }, err.statusCode as 400)
|
|
50
|
+
}
|
|
51
|
+
return c.json({ success: false, error: 'Internal error' }, 500)
|
|
52
|
+
})
|
|
53
|
+
app.use('*', tenantIsolationMiddleware())
|
|
54
|
+
app.get('/api/test', c => {
|
|
55
|
+
const tenant = c.get('tenant')
|
|
56
|
+
return c.json({ ok: true, tenantId: tenant.id, tenantSlug: tenant.slug })
|
|
57
|
+
})
|
|
58
|
+
return app
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('should return error when no tenant header or subdomain', async () => {
|
|
62
|
+
const app = createApp()
|
|
63
|
+
const res = await app.request('/api/test')
|
|
64
|
+
expect(res.status).toBe(404)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should return error when tenant header is empty string', async () => {
|
|
68
|
+
const app = createApp()
|
|
69
|
+
const res = await app.request('/api/test', {
|
|
70
|
+
headers: { 'X-Tenant-Slug': '' },
|
|
71
|
+
})
|
|
72
|
+
expect(res.status).toBe(404)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should pass with valid tenant from header', async () => {
|
|
76
|
+
mockDbResult([
|
|
77
|
+
{ id: 1, slug: 'test-tenant', name: 'Test', status: 'active', plan: 'free', maxUsers: 10, settings: null },
|
|
78
|
+
])
|
|
79
|
+
const app = createApp()
|
|
80
|
+
const res = await app.request('/api/test', {
|
|
81
|
+
headers: { 'X-Tenant-Slug': 'test-tenant' },
|
|
82
|
+
})
|
|
83
|
+
expect(res.status).toBe(200)
|
|
84
|
+
const json = (await res.json()) as { ok: boolean; tenantId: number; tenantSlug: string }
|
|
85
|
+
expect(json.ok).toBe(true)
|
|
86
|
+
expect(json.tenantId).toBe(1)
|
|
87
|
+
expect(json.tenantSlug).toBe('test-tenant')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should return 404 for non-existent tenant', async () => {
|
|
91
|
+
mockDbResult([])
|
|
92
|
+
const app = createApp()
|
|
93
|
+
const res = await app.request('/api/test', {
|
|
94
|
+
headers: { 'X-Tenant-Slug': 'nonexistent' },
|
|
95
|
+
})
|
|
96
|
+
expect(res.status).toBe(404)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should extract tenant from subdomain when no header', async () => {
|
|
100
|
+
mockDbResult([
|
|
101
|
+
{ id: 2, slug: 'acme', name: 'Acme', status: 'active', plan: 'pro', maxUsers: 100, settings: null },
|
|
102
|
+
])
|
|
103
|
+
const app = createApp()
|
|
104
|
+
const res = await app.request('/api/test', {
|
|
105
|
+
headers: { Host: 'acme.example.com' },
|
|
106
|
+
})
|
|
107
|
+
expect(res.status).toBe(200)
|
|
108
|
+
const json = (await res.json()) as { tenantSlug: string }
|
|
109
|
+
expect(json.tenantSlug).toBe('acme')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should prefer X-Tenant-Slug header over subdomain', async () => {
|
|
113
|
+
mockDbResult([
|
|
114
|
+
{ id: 3, slug: 'header-tenant', name: 'Header', status: 'active', plan: 'free', maxUsers: 5, settings: null },
|
|
115
|
+
])
|
|
116
|
+
const app = createApp()
|
|
117
|
+
const res = await app.request('/api/test', {
|
|
118
|
+
headers: { Host: 'subdomain.example.com', 'X-Tenant-Slug': 'header-tenant' },
|
|
119
|
+
})
|
|
120
|
+
expect(res.status).toBe(200)
|
|
121
|
+
const json = (await res.json()) as { tenantSlug: string }
|
|
122
|
+
expect(json.tenantSlug).toBe('header-tenant')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should parse tenant settings JSON', async () => {
|
|
126
|
+
mockDbResult([
|
|
127
|
+
{ id: 4, slug: 'with-settings', name: 'Settings', status: 'active', plan: 'pro', maxUsers: 50, settings: '{"theme":"dark"}' },
|
|
128
|
+
])
|
|
129
|
+
const app = createApp()
|
|
130
|
+
const res = await app.request('/api/test', {
|
|
131
|
+
headers: { 'X-Tenant-Slug': 'with-settings' },
|
|
132
|
+
})
|
|
133
|
+
expect(res.status).toBe(200)
|
|
134
|
+
const json = (await res.json()) as { tenantId: number }
|
|
135
|
+
expect(json.tenantId).toBe(4)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('should handle null settings gracefully', async () => {
|
|
139
|
+
mockDbResult([
|
|
140
|
+
{ id: 5, slug: 'no-settings', name: 'NoSettings', status: 'active', plan: 'free', maxUsers: 5, settings: null },
|
|
141
|
+
])
|
|
142
|
+
const app = createApp()
|
|
143
|
+
const res = await app.request('/api/test', {
|
|
144
|
+
headers: { 'X-Tenant-Slug': 'no-settings' },
|
|
145
|
+
})
|
|
146
|
+
expect(res.status).toBe(200)
|
|
147
|
+
const json = (await res.json()) as { tenantId: number }
|
|
148
|
+
expect(json.tenantId).toBe(5)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -3,7 +3,7 @@ import { createTestClient } from '@server/test-utils/test-client'
|
|
|
3
3
|
import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
|
|
4
4
|
import { getRawClient } from '@server/db'
|
|
5
5
|
import { Role } from '@shared/modules/permission'
|
|
6
|
-
import * as notificationService from '@server/module-notifications
|
|
6
|
+
import * as notificationService from '@server/module-notifications'
|
|
7
7
|
|
|
8
8
|
function authedClient(token = 'admin-token') {
|
|
9
9
|
return createTestClient(undefined, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRoute, z } from '@hono/zod-openapi'
|
|
2
2
|
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
3
|
import { authMiddleware, type AuthUser } from '@server/middleware/auth'
|
|
4
|
-
import * as notificationService from '@server/module-notifications
|
|
4
|
+
import * as notificationService from '@server/module-notifications'
|
|
5
5
|
import { realtime } from '@server/core'
|
|
6
6
|
import { successResponse, errorResponse, success } from '@server/utils/route-helpers'
|
|
7
7
|
import { logger } from '@server/utils/logger'
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
TestNotificationRequestSchema,
|
|
16
16
|
AppSSEProtocolSchema,
|
|
17
17
|
} from '@shared/modules/notifications'
|
|
18
|
-
import {
|
|
18
|
+
import { AdminSuccessSchema } from '@shared/modules/admin'
|
|
19
19
|
|
|
20
20
|
function createFallbackSSEResponse(): Response {
|
|
21
21
|
const stream = new ReadableStream({
|
|
@@ -83,7 +83,7 @@ const markNotificationReadRoute = createRoute({
|
|
|
83
83
|
}),
|
|
84
84
|
},
|
|
85
85
|
responses: {
|
|
86
|
-
200: successResponse(
|
|
86
|
+
200: successResponse(AdminSuccessSchema, 'Notification marked as read'),
|
|
87
87
|
401: errorResponse('Unauthorized'),
|
|
88
88
|
404: errorResponse('Notification not found'),
|
|
89
89
|
},
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
UserListSchema,
|
|
10
10
|
UpdateUserRequestSchema,
|
|
11
11
|
CreateUserRequestSchema,
|
|
12
|
-
|
|
12
|
+
AdminSuccessSchema,
|
|
13
13
|
} from '@shared/modules/admin'
|
|
14
14
|
|
|
15
15
|
const getUsersRoute = createRoute({
|
|
@@ -82,7 +82,7 @@ const deleteUserRoute = createRoute({
|
|
|
82
82
|
}),
|
|
83
83
|
},
|
|
84
84
|
responses: {
|
|
85
|
-
200: successResponse(
|
|
85
|
+
200: successResponse(AdminSuccessSchema, 'User deleted'),
|
|
86
86
|
401: errorResponse('Unauthorized'),
|
|
87
87
|
403: errorResponse('Forbidden'),
|
|
88
88
|
404: errorResponse('User not found'),
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
import { getRawClient, getDb } from '@server/db'
|
|
4
|
+
import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
|
|
5
|
+
import { hashSync } from 'bcryptjs'
|
|
6
|
+
import jwt from 'jsonwebtoken'
|
|
7
|
+
import { authRoutes } from '../routes/auth-routes'
|
|
8
|
+
|
|
9
|
+
const secretKey = process.env.AUTH_SECRET_KEY || 'dev-secret-key-change-in-production'
|
|
10
|
+
|
|
11
|
+
describe('Auth Routes', () => {
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
await setupTestDatabase()
|
|
14
|
+
const db = await getDb()
|
|
15
|
+
expect(db).toBeDefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await cleanupTestDatabase()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
const client = await getRawClient()
|
|
24
|
+
if (client && 'execute' in client) {
|
|
25
|
+
await client.execute('DELETE FROM developers')
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
const client = await getRawClient()
|
|
31
|
+
if (client && 'execute' in client) {
|
|
32
|
+
await client.execute('DELETE FROM developers')
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
async function insertTestDeveloper(overrides: Record<string, unknown> = {}) {
|
|
37
|
+
const client = await getRawClient()
|
|
38
|
+
if (!client || !('execute' in client)) throw new Error('No DB client')
|
|
39
|
+
|
|
40
|
+
const id = (overrides.id as string) || 'dev-test-id'
|
|
41
|
+
const username = (overrides.username as string) || 'testuser'
|
|
42
|
+
const email = (overrides.email as string) || 'test@example.com'
|
|
43
|
+
const passwordHash = (overrides.passwordHash as string) || hashSync('password123', 10)
|
|
44
|
+
const apiKey = (overrides.apiKey as string) || 'ak_test-api-key'
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
|
|
47
|
+
await client.execute({
|
|
48
|
+
sql: `INSERT INTO developers (id, username, email, password_hash, role, api_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
49
|
+
args: [id, username, email, passwordHash, 'developer', apiKey, now, now],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return { id, username, email, apiKey, passwordHash }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function registerUser(username: string, email: string, password: string) {
|
|
56
|
+
return authRoutes.fetch(
|
|
57
|
+
new Request('http://localhost/auth/register', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ username, email, password }),
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loginUser(payload: Record<string, string>) {
|
|
66
|
+
return authRoutes.fetch(
|
|
67
|
+
new Request('http://localhost/auth/login', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(payload),
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('POST /auth/register', () => {
|
|
76
|
+
it('should register a new developer and return 201', async () => {
|
|
77
|
+
const res = await registerUser('newdev', 'newdev@example.com', 'password123')
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(201)
|
|
80
|
+
const data = (await res.json()) as { success: boolean; data: { token: string; profile: { username: string; email: string; role: string; id: string } } }
|
|
81
|
+
expect(data.success).toBe(true)
|
|
82
|
+
expect(data.data.token).toBeDefined()
|
|
83
|
+
expect(typeof data.data.token).toBe('string')
|
|
84
|
+
expect(data.data.profile.username).toBe('newdev')
|
|
85
|
+
expect(data.data.profile.email).toBe('newdev@example.com')
|
|
86
|
+
expect(data.data.profile.role).toBe('developer')
|
|
87
|
+
expect(data.data.profile.id).toBeDefined()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should return a valid JWT token', async () => {
|
|
91
|
+
const res = await registerUser('jwtuser', 'jwt@example.com', 'password123')
|
|
92
|
+
const data = (await res.json()) as { success: boolean; data: { token: string } }
|
|
93
|
+
expect(data.success).toBe(true)
|
|
94
|
+
|
|
95
|
+
const decoded = jwt.verify(data.data.token, secretKey) as jwt.JwtPayload
|
|
96
|
+
expect(decoded.username).toBe('jwtuser')
|
|
97
|
+
expect(decoded.email).toBe('jwt@example.com')
|
|
98
|
+
expect(decoded.role).toBe('developer')
|
|
99
|
+
expect(decoded.userId).toBeDefined()
|
|
100
|
+
expect(decoded.exp).toBeGreaterThan(Date.now() / 1000)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should set token expiry to 7 days', async () => {
|
|
104
|
+
const res = await registerUser('expiryuser', 'expiry@example.com', 'password123')
|
|
105
|
+
const data = (await res.json()) as { success: boolean; data: { token: string } }
|
|
106
|
+
|
|
107
|
+
const decoded = jwt.verify(data.data.token, secretKey) as jwt.JwtPayload
|
|
108
|
+
const expectedExp = decoded.iat! + 7 * 24 * 60 * 60
|
|
109
|
+
expect(decoded.exp).toBe(expectedExp)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should return error on duplicate email', async () => {
|
|
113
|
+
await insertTestDeveloper({ email: 'dup@example.com' })
|
|
114
|
+
const res = await registerUser('another', 'dup@example.com', 'password123')
|
|
115
|
+
expect(res.status).toBeGreaterThanOrEqual(400)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should return error on duplicate username', async () => {
|
|
119
|
+
await insertTestDeveloper({ username: 'dupuser' })
|
|
120
|
+
const res = await registerUser('dupuser', 'unique@example.com', 'password123')
|
|
121
|
+
expect(res.status).toBeGreaterThanOrEqual(400)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should return 400 with missing username', async () => {
|
|
125
|
+
const res = await authRoutes.fetch(
|
|
126
|
+
new Request('http://localhost/auth/register', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({ email: 'noUser@example.com', password: 'password123' }),
|
|
130
|
+
})
|
|
131
|
+
)
|
|
132
|
+
expect(res.status).toBe(400)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should return 400 with missing email', async () => {
|
|
136
|
+
const res = await authRoutes.fetch(
|
|
137
|
+
new Request('http://localhost/auth/register', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ username: 'noemail', password: 'password123' }),
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
expect(res.status).toBe(400)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should return 400 with invalid email format', async () => {
|
|
147
|
+
const res = await registerUser('bademail', 'not-an-email', 'password123')
|
|
148
|
+
expect(res.status).toBe(400)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should return 400 with password shorter than 6 chars', async () => {
|
|
152
|
+
const res = await registerUser('shortpw', 'short@example.com', '12345')
|
|
153
|
+
expect(res.status).toBe(400)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('should return 400 with username shorter than 3 chars', async () => {
|
|
157
|
+
const res = await registerUser('ab', 'shortname@example.com', 'password123')
|
|
158
|
+
expect(res.status).toBe(400)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should return 400 with username longer than 50 chars', async () => {
|
|
162
|
+
const res = await registerUser('a'.repeat(51), 'long@example.com', 'password123')
|
|
163
|
+
expect(res.status).toBe(400)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should return profile with createdAt timestamp', async () => {
|
|
167
|
+
const res = await registerUser('tsuser', 'ts@example.com', 'password123')
|
|
168
|
+
const data = (await res.json()) as { success: boolean; data: { profile: { createdAt: string } } }
|
|
169
|
+
expect(data.success).toBe(true)
|
|
170
|
+
expect(data.data.profile.createdAt).toBeDefined()
|
|
171
|
+
expect(data.data.profile.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('POST /auth/login', () => {
|
|
176
|
+
it('should login with email and return 200', async () => {
|
|
177
|
+
await insertTestDeveloper({ email: 'login@example.com', username: 'loginuser' })
|
|
178
|
+
|
|
179
|
+
const res = await loginUser({ email: 'login@example.com', password: 'password123' })
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(200)
|
|
182
|
+
const data = (await res.json()) as { success: boolean; data: { token: string; profile: { email: string; username: string } } }
|
|
183
|
+
expect(data.success).toBe(true)
|
|
184
|
+
expect(data.data.token).toBeDefined()
|
|
185
|
+
expect(data.data.profile.email).toBe('login@example.com')
|
|
186
|
+
expect(data.data.profile.username).toBe('loginuser')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('should login with account field using username', async () => {
|
|
190
|
+
await insertTestDeveloper({ email: 'acct@example.com', username: 'acctuser' })
|
|
191
|
+
|
|
192
|
+
const res = await loginUser({ account: 'acctuser', password: 'password123' })
|
|
193
|
+
|
|
194
|
+
expect(res.status).toBe(200)
|
|
195
|
+
const data = (await res.json()) as { success: boolean; data: { profile: { username: string } } }
|
|
196
|
+
expect(data.success).toBe(true)
|
|
197
|
+
expect(data.data.profile.username).toBe('acctuser')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should login with account field using email', async () => {
|
|
201
|
+
await insertTestDeveloper({ email: 'acctemail@example.com', username: 'acctemailuser' })
|
|
202
|
+
|
|
203
|
+
const res = await loginUser({ account: 'acctemail@example.com', password: 'password123' })
|
|
204
|
+
|
|
205
|
+
expect(res.status).toBe(200)
|
|
206
|
+
const data = (await res.json()) as { success: boolean; data: { profile: { email: string } } }
|
|
207
|
+
expect(data.success).toBe(true)
|
|
208
|
+
expect(data.data.profile.email).toBe('acctemail@example.com')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should return a valid JWT token', async () => {
|
|
212
|
+
await insertTestDeveloper({
|
|
213
|
+
id: 'jwt-login-id',
|
|
214
|
+
email: 'jwtlogin@example.com',
|
|
215
|
+
username: 'jwtlogin',
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const res = await loginUser({ email: 'jwtlogin@example.com', password: 'password123' })
|
|
219
|
+
const data = (await res.json()) as { success: boolean; data: { token: string } }
|
|
220
|
+
expect(data.success).toBe(true)
|
|
221
|
+
|
|
222
|
+
const decoded = jwt.verify(data.data.token, secretKey) as jwt.JwtPayload
|
|
223
|
+
expect(decoded.userId).toBe('jwt-login-id')
|
|
224
|
+
expect(decoded.username).toBe('jwtlogin')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should return error on wrong password', async () => {
|
|
228
|
+
await insertTestDeveloper({ email: 'wrongpw@example.com' })
|
|
229
|
+
|
|
230
|
+
const res = await loginUser({ email: 'wrongpw@example.com', password: 'wrongpassword' })
|
|
231
|
+
|
|
232
|
+
expect(res.status).toBeGreaterThanOrEqual(400)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should return error with non-existent email', async () => {
|
|
236
|
+
const res = await loginUser({ email: 'nonexistent@example.com', password: 'password123' })
|
|
237
|
+
|
|
238
|
+
expect(res.status).toBeGreaterThanOrEqual(400)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('should return 400 with missing both account and email', async () => {
|
|
242
|
+
const res = await loginUser({ password: 'password123' })
|
|
243
|
+
expect(res.status).toBe(400)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should return 400 with password shorter than 6 chars', async () => {
|
|
247
|
+
const res = await loginUser({ email: 'any@example.com', password: '12345' })
|
|
248
|
+
expect(res.status).toBe(400)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should not return passwordHash in profile', async () => {
|
|
252
|
+
await insertTestDeveloper({ email: 'safe@example.com', username: 'safeuser' })
|
|
253
|
+
|
|
254
|
+
const res = await loginUser({ email: 'safe@example.com', password: 'password123' })
|
|
255
|
+
const data = (await res.json()) as { success: boolean; data: { profile: Record<string, unknown> } }
|
|
256
|
+
expect(data.success).toBe(true)
|
|
257
|
+
|
|
258
|
+
const profile = data.data.profile as Record<string, unknown>
|
|
259
|
+
expect(profile).not.toHaveProperty('passwordHash')
|
|
260
|
+
expect(profile).not.toHaveProperty('password_hash')
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe('Register then Login flow', () => {
|
|
265
|
+
it('should register and then login with same credentials', async () => {
|
|
266
|
+
const registerRes = await registerUser('flowuser', 'flow@example.com', 'password123')
|
|
267
|
+
expect(registerRes.status).toBe(201)
|
|
268
|
+
|
|
269
|
+
const loginRes = await loginUser({ email: 'flow@example.com', password: 'password123' })
|
|
270
|
+
expect(loginRes.status).toBe(200)
|
|
271
|
+
|
|
272
|
+
const loginData = (await loginRes.json()) as { success: boolean; data: { profile: { username: string; email: string } } }
|
|
273
|
+
expect(loginData.success).toBe(true)
|
|
274
|
+
expect(loginData.data.profile.username).toBe('flowuser')
|
|
275
|
+
expect(loginData.data.profile.email).toBe('flow@example.com')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should register and verify token contains correct claims', async () => {
|
|
279
|
+
const res = await registerUser('verifyflow', 'verifyflow@example.com', 'password123')
|
|
280
|
+
const data = (await res.json()) as { success: boolean; data: { token: string } }
|
|
281
|
+
expect(data.success).toBe(true)
|
|
282
|
+
|
|
283
|
+
const decoded = jwt.verify(data.data.token, secretKey) as jwt.JwtPayload
|
|
284
|
+
expect(decoded.username).toBe('verifyflow')
|
|
285
|
+
expect(decoded.email).toBe('verifyflow@example.com')
|
|
286
|
+
expect(decoded.role).toBe('developer')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should register two developers with unique IDs and tokens', async () => {
|
|
290
|
+
const res1 = await registerUser('user1', 'user1@example.com', 'password123')
|
|
291
|
+
const res2 = await registerUser('user2', 'user2@example.com', 'password123')
|
|
292
|
+
|
|
293
|
+
const data1 = (await res1.json()) as { success: boolean; data: { profile: { id: string }; token: string } }
|
|
294
|
+
const data2 = (await res2.json()) as { success: boolean; data: { profile: { id: string }; token: string } }
|
|
295
|
+
expect(data1.success).toBe(true)
|
|
296
|
+
expect(data2.success).toBe(true)
|
|
297
|
+
expect(data1.data.profile.id).not.toBe(data2.data.profile.id)
|
|
298
|
+
expect(data1.data.token).not.toBe(data2.data.token)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('should register, login, and get matching profile data', async () => {
|
|
302
|
+
const regRes = await registerUser('matchuser', 'match@example.com', 'password123')
|
|
303
|
+
const regData = (await regRes.json()) as { success: boolean; data: { profile: { id: string; username: string; email: string } } }
|
|
304
|
+
const regProfile = regData.data.profile
|
|
305
|
+
|
|
306
|
+
const loginRes = await loginUser({ email: 'match@example.com', password: 'password123' })
|
|
307
|
+
const loginData = (await loginRes.json()) as { success: boolean; data: { profile: { id: string; username: string; email: string } } }
|
|
308
|
+
const loginProfile = loginData.data.profile
|
|
309
|
+
|
|
310
|
+
expect(regProfile.id).toBe(loginProfile.id)
|
|
311
|
+
expect(regProfile.username).toBe(loginProfile.username)
|
|
312
|
+
expect(regProfile.email).toBe(loginProfile.email)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
3
|
+
import { profileRoutes } from '../routes/profile-routes'
|
|
4
|
+
|
|
5
|
+
describe('Profile Routes', () => {
|
|
6
|
+
describe('GET /profile', () => {
|
|
7
|
+
it('should return profile with 200', async () => {
|
|
8
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
9
|
+
|
|
10
|
+
expect(res.status).toBe(200)
|
|
11
|
+
const data = (await res.json()) as Record<string, any>
|
|
12
|
+
expect(data.success).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should return profile with required fields', async () => {
|
|
16
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
17
|
+
const data = (await res.json()) as Record<string, any>
|
|
18
|
+
|
|
19
|
+
expect(data.success).toBe(true)
|
|
20
|
+
expect(data.data.id).toBeDefined()
|
|
21
|
+
expect(data.data.username).toBeDefined()
|
|
22
|
+
expect(data.data.email).toBeDefined()
|
|
23
|
+
expect(data.data.joinDate).toBeDefined()
|
|
24
|
+
expect(data.data.stats).toBeDefined()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should return valid email format', async () => {
|
|
28
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
29
|
+
const data = (await res.json()) as Record<string, any>
|
|
30
|
+
|
|
31
|
+
expect(data.success).toBe(true)
|
|
32
|
+
expect(data.data.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should return stats with numeric values', async () => {
|
|
36
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
37
|
+
const data = (await res.json()) as Record<string, any>
|
|
38
|
+
|
|
39
|
+
expect(data.success).toBe(true)
|
|
40
|
+
expect(typeof data.data.stats.posts).toBe('number')
|
|
41
|
+
expect(typeof data.data.stats.followers).toBe('number')
|
|
42
|
+
expect(typeof data.data.stats.following).toBe('number')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should return a valid ISO date for joinDate', async () => {
|
|
46
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
47
|
+
const data = (await res.json()) as Record<string, any>
|
|
48
|
+
|
|
49
|
+
expect(data.success).toBe(true)
|
|
50
|
+
expect(data.data.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should return consistent data across requests', async () => {
|
|
54
|
+
const res1 = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
55
|
+
const data1 = (await res1.json()) as Record<string, any>
|
|
56
|
+
const res2 = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
57
|
+
const data2 = (await res2.json()) as Record<string, any>
|
|
58
|
+
|
|
59
|
+
expect(data1.success).toBe(true)
|
|
60
|
+
expect(data2.success).toBe(true)
|
|
61
|
+
expect(data1.data.id).toBe(data2.data.id)
|
|
62
|
+
expect(data1.data.username).toBe(data2.data.username)
|
|
63
|
+
expect(data1.data.email).toBe(data2.data.email)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should include timestamp in response', async () => {
|
|
67
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
68
|
+
const data = (await res.json()) as Record<string, any>
|
|
69
|
+
|
|
70
|
+
expect(data.success).toBe(true)
|
|
71
|
+
expect(data.timestamp).toBeDefined()
|
|
72
|
+
expect(data.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should return bio field', async () => {
|
|
76
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
77
|
+
const data = (await res.json()) as Record<string, any>
|
|
78
|
+
|
|
79
|
+
expect(data.success).toBe(true)
|
|
80
|
+
expect(data.data.bio).toBeDefined()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|