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 { Hono } from 'hono'
|
||||||
import { serve } from '@hono/node-server'
|
import { serve } from '@hono/node-server'
|
||||||
|
import authRoutes from './routes/auth'
|
||||||
|
import plansRoutes from './routes/plans'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
|
|
@ -7,6 +9,9 @@ app.get('/', (c) => {
|
||||||
return c.json({ message: 'Expria API — OK' }, 200)
|
return c.json({ message: 'Expria API — OK' }, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.route('/auth', authRoutes)
|
||||||
|
app.route('/plans', plansRoutes)
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3000
|
const port = Number(process.env.PORT) || 3000
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port }, () => {
|
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