// Grace-period middleware tests. We mock the supabase client at the // module boundary so the middleware's calls land on a controllable // chainable fake. No real DB / network. const mockSupabaseUpdates = []; const supabaseFake = { from(table) { const ctx = { table, filters: [], action: null }; const proxy = { update(patch) { ctx.patch = patch; ctx.action = 'update'; mockSupabaseUpdates.push(ctx); return proxy; }, eq(col, val) { ctx.filters.push([col, val]); // The middleware awaits the result of .eq() after .update() — // return a resolved promise (no error). return Promise.resolve({ error: null }); }, }; return proxy; }, }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => supabaseFake, })); const { checkGracePeriod } = require('../../src/middleware/gracePeriod'); beforeEach(() => { mockSupabaseUpdates.length = 0; }); function runMiddleware(req) { return new Promise((resolve, reject) => { const res = { status: (code) => ({ json: (b) => reject(new Error(`unexpected ${code}: ${JSON.stringify(b)}`)) }), }; checkGracePeriod(req, res, resolve); }); } describe('checkGracePeriod', () => { test('passes through when no user (unauth route slip)', async () => { await runMiddleware({}); expect(mockSupabaseUpdates).toHaveLength(0); }); test('passes through when user has no grace_period_until', async () => { await runMiddleware({ user: { id: 'u1', tier: 'analyst', grace_period_until: null } }); expect(mockSupabaseUpdates).toHaveLength(0); }); test('passes through when grace is still in the future', async () => { const future = new Date(Date.now() + 12 * 3600 * 1000).toISOString(); const req = { user: { id: 'u1', tier: 'analyst', grace_period_until: future } }; await runMiddleware(req); expect(mockSupabaseUpdates).toHaveLength(0); expect(req.user.tier).toBe('analyst'); // unchanged }); test('expired grace → downgrades both tables + rewrites req.user', async () => { const past = new Date(Date.now() - 60_000).toISOString(); const req = { user: { id: 'u1', tier: 'desk', grace_period_until: past } }; await runMiddleware(req); expect(mockSupabaseUpdates).toHaveLength(2); const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users'); expect(usersUpdate.patch).toEqual({ tier: 'free', grace_period_until: null }); expect(usersUpdate.filters).toEqual([['id', 'u1']]); const profileUpdate = mockSupabaseUpdates.find((u) => u.table === 'user_profiles'); expect(profileUpdate.patch.tier).toBe('free'); expect(profileUpdate.patch.subscription_status).toBe('expired'); expect(profileUpdate.patch.grace_period_until).toBeNull(); // req.user reflects the downgrade so the downstream route sees it. expect(req.user.tier).toBe('free'); expect(req.user.grace_period_until).toBeNull(); }); test('expired but already free → still scrubs the grace timestamp', async () => { // Edge case — Stripe set grace, then user got downgraded by some // other path. We should still null the timestamp so the row is clean. const past = new Date(Date.now() - 60_000).toISOString(); const req = { user: { id: 'u2', tier: 'free', grace_period_until: past } }; await runMiddleware(req); const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users'); expect(usersUpdate.patch.grace_period_until).toBeNull(); expect(req.user.grace_period_until).toBeNull(); }); test('malformed grace timestamp → treated as no grace (no downgrade)', async () => { const req = { user: { id: 'u3', tier: 'analyst', grace_period_until: 'not-a-date' } }; await runMiddleware(req); expect(mockSupabaseUpdates).toHaveLength(0); expect(req.user.tier).toBe('analyst'); }); test('exactly-at-boundary timestamp is treated as expired', async () => { // The middleware does `> Date.now()`; an exact match counts as // expired. We test that policy stays explicit. const req = { user: { id: 'u4', tier: 'analyst', grace_period_until: new Date(Date.now() - 1).toISOString() } }; await runMiddleware(req); expect(mockSupabaseUpdates.length).toBeGreaterThan(0); expect(req.user.tier).toBe('free'); }); });