153 lines
4.6 KiB
JavaScript
153 lines
4.6 KiB
JavaScript
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
|
|
||
|
|
vi.mock('../../lib/sql.js', () => ({
|
||
|
|
sql: vi.fn((strings, ...values) => {
|
||
|
|
if (values.length === 0) {
|
||
|
|
return { __sqlFragment: strings.join('') };
|
||
|
|
}
|
||
|
|
return Promise.resolve({ rows: [] });
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
vi.mock('../../lib/permission-middleware.js', () => ({
|
||
|
|
getUserFromRequest: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { sql } from '../../lib/sql.js';
|
||
|
|
import { getUserFromRequest } from '../../lib/permission-middleware.js';
|
||
|
|
import { effectiveUnitPriceExpr } from '../../lib/pricing/collection-valuation.js';
|
||
|
|
import handler from '../../pages/api/user/stats.js';
|
||
|
|
|
||
|
|
const UNIT_PRICE_SQL = effectiveUnitPriceExpr({ cardAlias: 'cards', userCardAlias: 'user_cards' });
|
||
|
|
|
||
|
|
function createRes() {
|
||
|
|
const res = {
|
||
|
|
statusCode: 200,
|
||
|
|
body: null,
|
||
|
|
status(code) {
|
||
|
|
res.statusCode = code;
|
||
|
|
return res;
|
||
|
|
},
|
||
|
|
json(data) {
|
||
|
|
res.body = data;
|
||
|
|
return res;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
return res;
|
||
|
|
}
|
||
|
|
|
||
|
|
function queryText(call) {
|
||
|
|
const [strings, ...values] = call;
|
||
|
|
const fragments = values.map((value) =>
|
||
|
|
value && typeof value === 'object' && value.__sqlFragment ? value.__sqlFragment : '?'
|
||
|
|
);
|
||
|
|
return strings.reduce((text, part, index) => text + part + (fragments[index] ?? ''), '');
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('GET /api/user/stats foil-aware valuation', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||
|
|
|
||
|
|
sql.mockImplementation((strings, ...values) => {
|
||
|
|
if (values.length === 0) {
|
||
|
|
return { __sqlFragment: strings.join('') };
|
||
|
|
}
|
||
|
|
return Promise.resolve({ rows: [] });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses effective unit price SQL for totals and breakdowns', async () => {
|
||
|
|
sql.mockImplementation((strings, ...values) => {
|
||
|
|
if (values.length === 0) {
|
||
|
|
return { __sqlFragment: strings.join('') };
|
||
|
|
}
|
||
|
|
|
||
|
|
const text = queryText([strings, ...values]);
|
||
|
|
|
||
|
|
if (text.includes('SUM') && text.includes('total_value') && !text.includes('GROUP BY')) {
|
||
|
|
return Promise.resolve({ rows: [{ total_value: '6.50' }] });
|
||
|
|
}
|
||
|
|
if (text.includes('GROUP BY cards.game')) {
|
||
|
|
return Promise.resolve({
|
||
|
|
rows: [{ game: 'MTG', unique_cards: '2', total_quantity: '3', total_value: '6.50' }],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (text.includes('GROUP BY cards.rarity')) {
|
||
|
|
return Promise.resolve({
|
||
|
|
rows: [{ rarity: 'rare', unique_cards: '2', total_quantity: '3', total_value: '6.50' }],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (text.includes('ORDER BY user_cards.created_at DESC')) {
|
||
|
|
return Promise.resolve({
|
||
|
|
rows: [
|
||
|
|
{
|
||
|
|
name: 'Lightning Bolt',
|
||
|
|
game: 'MTG',
|
||
|
|
rarity: 'common',
|
||
|
|
image_url: null,
|
||
|
|
market_price: '1.00',
|
||
|
|
quantity: 2,
|
||
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'Lightning Bolt',
|
||
|
|
game: 'MTG',
|
||
|
|
rarity: 'common',
|
||
|
|
image_url: null,
|
||
|
|
market_price: '4.50',
|
||
|
|
quantity: 1,
|
||
|
|
created_at: '2026-01-02T00:00:00.000Z',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (text.includes('total_cards')) {
|
||
|
|
return Promise.resolve({ rows: [{ total_cards: '3' }] });
|
||
|
|
}
|
||
|
|
if (text.includes('total_collections')) {
|
||
|
|
return Promise.resolve({ rows: [{ total_collections: '1' }] });
|
||
|
|
}
|
||
|
|
if (text.includes('total_decks')) {
|
||
|
|
return Promise.resolve({ rows: [{ total_decks: '0' }] });
|
||
|
|
}
|
||
|
|
|
||
|
|
return Promise.resolve({ rows: [] });
|
||
|
|
});
|
||
|
|
|
||
|
|
const req = { method: 'GET' };
|
||
|
|
const res = createRes();
|
||
|
|
|
||
|
|
await handler(req, res);
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
expect(res.body.totalValue).toBe(6.5);
|
||
|
|
expect(res.body.recentActivity[0].marketPrice).toBe(1);
|
||
|
|
expect(res.body.recentActivity[1].marketPrice).toBe(4.5);
|
||
|
|
|
||
|
|
const valuationCalls = sql.mock.calls.filter((call) => queryText(call).includes('total_value'));
|
||
|
|
|
||
|
|
for (const call of valuationCalls) {
|
||
|
|
const text = queryText(call);
|
||
|
|
expect(text).toContain(UNIT_PRICE_SQL);
|
||
|
|
expect(text).not.toContain('cards.market_price * user_cards.quantity');
|
||
|
|
}
|
||
|
|
|
||
|
|
const recentActivityCall = sql.mock.calls.find((call) =>
|
||
|
|
queryText(call).includes('ORDER BY user_cards.created_at DESC')
|
||
|
|
);
|
||
|
|
expect(queryText(recentActivityCall)).toContain(`${UNIT_PRICE_SQL} as market_price`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 401 when unauthenticated', async () => {
|
||
|
|
getUserFromRequest.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const req = { method: 'GET' };
|
||
|
|
const res = createRes();
|
||
|
|
|
||
|
|
await handler(req, res);
|
||
|
|
|
||
|
|
expect(res.statusCode).toBe(401);
|
||
|
|
expect(sql).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|