Files
vyndr/tests/unit/gracePeriod.test.js
T

113 lines
4.3 KiB
JavaScript

// 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');
});
});