Session 7h: Stripe products, tier config, scan limits, response gating, free tier
This commit is contained in:
@@ -18,6 +18,12 @@ jest.mock('../../src/utils/redis', () => ({
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
// Session 7h: scan-limit middleware now mounts on /api/analyze. Reset
|
||||
// between tests so the cumulative anonymous-IP quota doesn't leak
|
||||
// across cases and 429 the cache assertions.
|
||||
const { __internals: mockScanLimitInternals } = require('../../src/middleware/scanLimit');
|
||||
beforeEach(() => mockScanLimitInternals.resetForTests());
|
||||
|
||||
const express = require('express');
|
||||
const analyze = require('../../src/routes/analyze');
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const { scanLimit, __internals } = require('../../src/middleware/scanLimit');
|
||||
|
||||
function mockReqRes({ ip = '1.1.1.1', tier, userId } = {}) {
|
||||
const req = {
|
||||
ip,
|
||||
socket: { remoteAddress: ip },
|
||||
headers: {},
|
||||
user: userId ? { id: userId, tier } : (tier ? { tier } : null),
|
||||
};
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
set(k, v) { headers[k] = v; return res; },
|
||||
status(c) { res.statusCode = c; return res; },
|
||||
json(b) { res.body = b; return res; },
|
||||
};
|
||||
return { req, res, headers };
|
||||
}
|
||||
|
||||
beforeEach(() => __internals.resetForTests());
|
||||
|
||||
describe('scanLimit middleware', () => {
|
||||
const mw = scanLimit();
|
||||
|
||||
test('free user allowed up to 3 scans per day', () => {
|
||||
const next = jest.fn();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-free' });
|
||||
mw(req, res, next);
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('free user 4th scan in the window returns 429', () => {
|
||||
const next = jest.fn();
|
||||
let lastRes;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-free-block' });
|
||||
mw(req, res, next);
|
||||
lastRes = res;
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
expect(lastRes.statusCode).toBe(429);
|
||||
expect(lastRes.body.error).toMatch(/scan limit/i);
|
||||
expect(lastRes.body.scans_used).toBe(3);
|
||||
expect(lastRes.body.scans_limit).toBe(3);
|
||||
expect(lastRes.body.tier).toBe('free');
|
||||
});
|
||||
|
||||
test('429 response includes Retry-After + X-Scans-* headers', () => {
|
||||
const next = jest.fn();
|
||||
let lastHeaders;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const { req, res, headers } = mockReqRes({ tier: 'free', userId: 'u-hdr' });
|
||||
mw(req, res, next);
|
||||
lastHeaders = headers;
|
||||
}
|
||||
expect(parseInt(lastHeaders['Retry-After'], 10)).toBeGreaterThan(0);
|
||||
expect(lastHeaders['X-Scans-Used']).toBe('3');
|
||||
expect(lastHeaders['X-Scans-Limit']).toBe('3');
|
||||
});
|
||||
|
||||
test('analyst tier gets 15 scans/day', () => {
|
||||
const next = jest.fn();
|
||||
let blockedAt = null;
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'analyst', userId: 'u-analyst' });
|
||||
mw(req, res, next);
|
||||
if (res.statusCode === 429 && blockedAt == null) blockedAt = i;
|
||||
}
|
||||
expect(blockedAt).toBe(15);
|
||||
expect(next).toHaveBeenCalledTimes(15);
|
||||
});
|
||||
|
||||
test('desk tier is unlimited — never blocks', () => {
|
||||
const next = jest.fn();
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'desk', userId: 'u-desk' });
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(100);
|
||||
});
|
||||
|
||||
test('anonymous user (no req.user) falls back to free quota keyed by IP', () => {
|
||||
const next = jest.fn();
|
||||
let lastRes;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const { req, res } = mockReqRes({ ip: '7.7.7.7' });
|
||||
mw(req, res, next);
|
||||
lastRes = res;
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
expect(lastRes.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
test('different IPs are independent (anonymous bucket)', () => {
|
||||
const next = jest.fn();
|
||||
const a = mockReqRes({ ip: '1.1.1.1' });
|
||||
const b = mockReqRes({ ip: '2.2.2.2' });
|
||||
mw(a.req, a.res, next);
|
||||
mw(b.req, b.res, next);
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(a.res.statusCode).toBe(200);
|
||||
expect(b.res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('authenticated users in the same IP get independent quotas', () => {
|
||||
const next = jest.fn();
|
||||
// Same IP, different user IDs — should not interfere.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-A', ip: '5.5.5.5' });
|
||||
mw(req, res, next);
|
||||
}
|
||||
// User A exhausted; user B starts clean.
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-B', ip: '5.5.5.5' });
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(next).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
const { applyTierGating, __internals } = require('../../src/utils/tierGating');
|
||||
|
||||
function sampleResult(overrides = {}) {
|
||||
return {
|
||||
player: 'Jalen Brunson',
|
||||
stat_type: 'points',
|
||||
line: 26.5,
|
||||
direction: 'over',
|
||||
book: 'draftkings',
|
||||
grade: 'A',
|
||||
confidence: 78,
|
||||
edge_pct: 6.2,
|
||||
kill_conditions_triggered: [
|
||||
{ code: 'TRAP', reason: 'Multiple trap signals firing.' },
|
||||
{ code: 'COLD_L5', reason: 'Last-5 average below the line.' },
|
||||
],
|
||||
reasoning: {
|
||||
summary: 'Brunson averaging 28.4 last 5; weak NYK defense; rested.',
|
||||
steps: { season_avg: { value: 26.1 }, final_grade: 'A' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyTierGating — free tier locks the explanation surface', () => {
|
||||
test('keeps grade + confidence + edge_pct (the hook)', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.grade).toBe('A');
|
||||
expect(gated.confidence).toBe(78);
|
||||
expect(gated.edge_pct).toBe(6.2);
|
||||
expect(gated.player).toBe('Jalen Brunson');
|
||||
expect(gated.stat_type).toBe('points');
|
||||
expect(gated.line).toBe(26.5);
|
||||
expect(gated.direction).toBe('over');
|
||||
});
|
||||
|
||||
test('redacts reasoning.summary + steps + marks locked', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.reasoning.summary).toBe(__internals.LOCKED_REASONING_SUMMARY);
|
||||
expect(gated.reasoning.steps).toBeNull();
|
||||
expect(gated.reasoning.locked).toBe(true);
|
||||
});
|
||||
|
||||
test('redacts kill condition reasons but keeps codes for badge rendering', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.kill_conditions_triggered).toHaveLength(2);
|
||||
for (const kc of gated.kill_conditions_triggered) {
|
||||
expect(kc.code).toBeDefined();
|
||||
expect(kc.reason).toBe(__internals.LOCKED_KILL_REASON);
|
||||
expect(kc.locked).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('tags response with tier_gated + upgrade_hint', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.tier_gated).toBe(true);
|
||||
expect(typeof gated.upgrade_hint).toBe('string');
|
||||
});
|
||||
|
||||
test('does not mutate the input result', () => {
|
||||
const original = sampleResult();
|
||||
applyTierGating(original, 'free');
|
||||
expect(original.reasoning.summary).toContain('Brunson averaging');
|
||||
expect(original.kill_conditions_triggered[0].reason).toContain('Multiple trap');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTierGating — paid tiers pass through unchanged', () => {
|
||||
test('analyst sees full reasoning', () => {
|
||||
const r = sampleResult();
|
||||
const out = applyTierGating(r, 'analyst');
|
||||
expect(out.reasoning.summary).toContain('Brunson averaging');
|
||||
expect(out.reasoning.locked).toBeUndefined();
|
||||
expect(out.tier_gated).toBeUndefined();
|
||||
expect(out.kill_conditions_triggered[0].reason).toContain('Multiple trap');
|
||||
});
|
||||
|
||||
test('desk sees full reasoning', () => {
|
||||
const out = applyTierGating(sampleResult(), 'desk');
|
||||
expect(out.reasoning.summary).toContain('Brunson averaging');
|
||||
expect(out.tier_gated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTierGating — edge cases', () => {
|
||||
test('null result returns null', () => {
|
||||
expect(applyTierGating(null, 'free')).toBeNull();
|
||||
expect(applyTierGating(undefined, 'free')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown tier name → treated as free', () => {
|
||||
const out = applyTierGating(sampleResult(), 'pirate');
|
||||
expect(out.reasoning.locked).toBe(true);
|
||||
});
|
||||
|
||||
test('result with no kill_conditions_triggered still produces a clean response', () => {
|
||||
const noKills = { ...sampleResult(), kill_conditions_triggered: [] };
|
||||
const out = applyTierGating(noKills, 'free');
|
||||
expect(out.kill_conditions_triggered).toEqual([]);
|
||||
expect(out.reasoning.locked).toBe(true);
|
||||
});
|
||||
|
||||
test('result with malformed reasoning still produces a locked placeholder', () => {
|
||||
const out = applyTierGating({ ...sampleResult(), reasoning: null }, 'free');
|
||||
expect(out.reasoning.summary).toBe(__internals.LOCKED_REASONING_SUMMARY);
|
||||
expect(out.reasoning.locked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const { TIERS, VALID_TIERS, getTier, getScanLimit, canAccess } = require('../../src/config/tiers');
|
||||
|
||||
describe('tiers config', () => {
|
||||
test('VALID_TIERS includes free, analyst, desk', () => {
|
||||
expect(VALID_TIERS).toEqual(['free', 'analyst', 'desk']);
|
||||
});
|
||||
|
||||
test('api_access is FALSE on every tier (non-negotiable)', () => {
|
||||
for (const tierName of VALID_TIERS) {
|
||||
expect(TIERS[tierName].api_access).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('free tier has the most restrictive defaults', () => {
|
||||
const free = TIERS.free;
|
||||
expect(free.scans_per_day).toBe(3);
|
||||
expect(free.reasoning_visible).toBe(false);
|
||||
expect(free.kill_conditions_detail).toBe(false);
|
||||
expect(free.alerts).toBe(false);
|
||||
expect(free.portfolio).toBe(false);
|
||||
expect(free.engine2).toBe(false);
|
||||
// Grade is the hook — it must be visible even on free.
|
||||
expect(free.grade_visible).toBe(true);
|
||||
// stat_dashboard is the entry product — visible to everyone.
|
||||
expect(free.stat_dashboard).toBe(true);
|
||||
});
|
||||
|
||||
test('analyst opens reasoning + kill conditions + alerts', () => {
|
||||
const a = TIERS.analyst;
|
||||
expect(a.scans_per_day).toBe(15);
|
||||
expect(a.reasoning_visible).toBe(true);
|
||||
expect(a.kill_conditions_detail).toBe(true);
|
||||
expect(a.alerts).toBe(true);
|
||||
// Still no portfolio or engine2 — those are Desk perks.
|
||||
expect(a.portfolio).toBe(false);
|
||||
expect(a.engine2).toBe(false);
|
||||
});
|
||||
|
||||
test('desk unlocks everything except api_access', () => {
|
||||
const d = TIERS.desk;
|
||||
expect(d.scans_per_day).toBe(Infinity);
|
||||
expect(d.reasoning_visible).toBe(true);
|
||||
expect(d.kill_conditions_detail).toBe(true);
|
||||
expect(d.alerts).toBe(true);
|
||||
expect(d.portfolio).toBe(true);
|
||||
expect(d.engine2).toBe(true);
|
||||
expect(d.api_access).toBe(false);
|
||||
});
|
||||
|
||||
test('getTier falls back to free for unknown tier names', () => {
|
||||
expect(getTier('mystery')).toBe(TIERS.free);
|
||||
expect(getTier(null)).toBe(TIERS.free);
|
||||
expect(getTier(undefined)).toBe(TIERS.free);
|
||||
expect(getTier('')).toBe(TIERS.free);
|
||||
});
|
||||
|
||||
test('getScanLimit returns the per-tier daily cap', () => {
|
||||
expect(getScanLimit('free')).toBe(3);
|
||||
expect(getScanLimit('analyst')).toBe(15);
|
||||
expect(getScanLimit('desk')).toBe(Infinity);
|
||||
});
|
||||
|
||||
test('canAccess returns the right boolean per (tier, feature) pair', () => {
|
||||
expect(canAccess('free', 'grade_visible')).toBe(true);
|
||||
expect(canAccess('free', 'reasoning_visible')).toBe(false);
|
||||
expect(canAccess('free', 'api_access')).toBe(false);
|
||||
expect(canAccess('analyst', 'reasoning_visible')).toBe(true);
|
||||
expect(canAccess('analyst', 'engine2')).toBe(false);
|
||||
expect(canAccess('desk', 'engine2')).toBe(true);
|
||||
expect(canAccess('desk', 'api_access')).toBe(false);
|
||||
});
|
||||
|
||||
test('canAccess returns false for unknown features (defensive)', () => {
|
||||
expect(canAccess('desk', 'no_such_feature')).toBe(false);
|
||||
});
|
||||
|
||||
test('TIERS objects are frozen — nobody can mutate the matrix at runtime', () => {
|
||||
expect(Object.isFrozen(TIERS)).toBe(true);
|
||||
expect(Object.isFrozen(TIERS.free)).toBe(true);
|
||||
expect(Object.isFrozen(TIERS.analyst)).toBe(true);
|
||||
expect(Object.isFrozen(TIERS.desk)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user