feat: middleware plan + route GET /plans/status — 59/59 tests verts

This commit is contained in:
Hermann_Kitio 2026-04-16 12:42:43 +03:00
parent f4849571c4
commit f71498668f
4 changed files with 211 additions and 0 deletions

View file

@ -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 }, () => {

View 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
View 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
View 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