feat: middleware plan + route GET /plans/status — 59/59 tests verts
This commit is contained in:
parent
f4849571c4
commit
f71498668f
4 changed files with 211 additions and 0 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import authRoutes from './routes/auth'
|
||||
import plansRoutes from './routes/plans'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
|
|
@ -7,6 +9,9 @@ app.get('/', (c) => {
|
|||
return c.json({ message: 'Expria API — OK' }, 200)
|
||||
})
|
||||
|
||||
app.route('/auth', authRoutes)
|
||||
app.route('/plans', plansRoutes)
|
||||
|
||||
const port = Number(process.env.PORT) || 3000
|
||||
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
|
|
|
|||
133
src/middleware/__tests__/plan.test.ts
Normal file
133
src/middleware/__tests__/plan.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { planMiddleware } from '../plan'
|
||||
import type { AppVariables } from '../auth'
|
||||
import type { Feature } from '../../lib/access'
|
||||
|
||||
function createTestApp(feature: Feature, profileOverrides: Partial<AppVariables['profile']> = {}) {
|
||||
const app = new Hono<{ Variables: AppVariables }>()
|
||||
|
||||
// Simule authMiddleware : pose le profil dans le contexte
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', { id: 'user-123', email: 'test@example.com' })
|
||||
c.set('profile', {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
plan: 'free',
|
||||
simulations_used: 0,
|
||||
stripe_customer_id: null,
|
||||
stripe_subscription_id: null,
|
||||
plan_expires_at: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...profileOverrides,
|
||||
})
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get('/test', planMiddleware(feature), (c) => c.json({ ok: true }, 200))
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
describe('planMiddleware', () => {
|
||||
|
||||
// Plan FREE — features bloquées
|
||||
it('free — exam_mode → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('exam_mode', { plan: 'free' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
expect(body.error).toBe(true)
|
||||
})
|
||||
|
||||
it('free — detailed_report → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('detailed_report', { plan: 'free' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
it('free — oral_t2_live → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('oral_t2_live', { plan: 'free' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
it('free — dashboard → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('dashboard', { plan: 'free' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
// Plan STANDARD — features autorisées
|
||||
it('standard — detailed_report → 200', async () => {
|
||||
const app = createTestApp('detailed_report', { plan: 'standard' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('standard — tips → 200', async () => {
|
||||
const app = createTestApp('tips', { plan: 'standard' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('standard — dashboard → 200', async () => {
|
||||
const app = createTestApp('dashboard', { plan: 'standard' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// Plan STANDARD — features bloquées
|
||||
it('standard — exam_mode → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('exam_mode', { plan: 'standard' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
it('standard — oral_t2_live → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('oral_t2_live', { plan: 'standard' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
// Plan PREMIUM — toutes les features autorisées
|
||||
it('premium — exam_mode → 200', async () => {
|
||||
const app = createTestApp('exam_mode', { plan: 'premium' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('premium — oral_t2_live → 200', async () => {
|
||||
const app = createTestApp('oral_t2_live', { plan: 'premium' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('premium — pattern_analysis → 200', async () => {
|
||||
const app = createTestApp('pattern_analysis', { plan: 'premium' })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// Plan inconnu — sécurité défensive
|
||||
it('plan inconnu → 403 PLAN_INSUFFICIENT', async () => {
|
||||
const app = createTestApp('exam_mode', { plan: 'super_premium' as any })
|
||||
const res = await app.request('/test')
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
})
|
||||
42
src/middleware/plan.ts
Normal file
42
src/middleware/plan.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Context, Next } from 'hono'
|
||||
import { getPlanPermissions } from '../lib/access'
|
||||
import type { Feature } from '../lib/access'
|
||||
import type { AppVariables } from './auth'
|
||||
|
||||
/**
|
||||
* Vérifie que le profil de l'utilisateur (posé par authMiddleware)
|
||||
* dispose de la permission requise.
|
||||
* DOIT toujours être précédé de authMiddleware dans la chaîne.
|
||||
*/
|
||||
export function planMiddleware(feature: Feature) {
|
||||
return async (c: Context<{ Variables: AppVariables }>, next: Next) => {
|
||||
const profile = c.get('profile')
|
||||
|
||||
let perms: ReturnType<typeof getPlanPermissions>
|
||||
try {
|
||||
perms = getPlanPermissions(profile.plan as 'free' | 'standard' | 'premium')
|
||||
} catch {
|
||||
return c.json(
|
||||
{
|
||||
error: true,
|
||||
code: 'PLAN_INSUFFICIENT',
|
||||
message: 'Plan invalide ou non reconnu.',
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
if (!perms[feature]) {
|
||||
return c.json(
|
||||
{
|
||||
error: true,
|
||||
code: 'PLAN_INSUFFICIENT',
|
||||
message: "Cette fonctionnalité n'est pas disponible pour votre plan.",
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
await next()
|
||||
}
|
||||
}
|
||||
31
src/routes/plans.ts
Normal file
31
src/routes/plans.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Hono } from 'hono'
|
||||
import { authMiddleware } from '../middleware/auth'
|
||||
import type { AppVariables } from '../middleware/auth'
|
||||
import { getPlanPermissions, PLANS } from '../lib/access'
|
||||
|
||||
const plans = new Hono<{ Variables: AppVariables }>()
|
||||
|
||||
plans.get('/status', authMiddleware, (c) => {
|
||||
const profile = c.get('profile')
|
||||
const plan = profile.plan as 'free' | 'standard' | 'premium'
|
||||
const permissions = getPlanPermissions(plan)
|
||||
|
||||
const simulationsLifetime = PLANS[plan].simulations_lifetime
|
||||
const simulationsRemaining =
|
||||
simulationsLifetime === null
|
||||
? null
|
||||
: Math.max(0, simulationsLifetime - profile.simulations_used)
|
||||
|
||||
return c.json(
|
||||
{
|
||||
plan: profile.plan,
|
||||
permissions,
|
||||
simulations_used: profile.simulations_used,
|
||||
simulations_remaining: simulationsRemaining,
|
||||
plan_expires_at: profile.plan_expires_at,
|
||||
},
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
export default plans
|
||||
Loading…
Add table
Add a link
Reference in a new issue