Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions server/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,91 @@ describe("Admin audit logs (issue #539)", () => {
expect(res.status).toBe(200);
});
});

describe("GET /api/v1/admin/users query count (issue #480)", () => {
const adminId = "99999999-9999-9999-9999-999999999999";
const adminToken = jwt.sign(
{ sub: adminId, role: "admin", tokenVersion: 1 },
JWT_SECRET,
{ expiresIn: "1h" }
);

function makeUsers(n: number) {
return Array.from({ length: n }, (_, i) => ({
id: `00000000-0000-0000-0000-${String(i).padStart(12, "0")}`,
phone: `+23480000${String(i).padStart(5, "0")}`,
registrationDate: "2026-01-01T00:00:00.000Z",
balance: i === 0 ? 0 : 10 * i,
tradeCount: i === 0 ? 0 : i,
}));
}

// Answers the auth middleware, the page query and the total count; any
// other statement (e.g. a per-user follow-up) fails the test.
function wireDb(pageRows: ReturnType<typeof makeUsers>, total: number) {
mockQuery.mockImplementation(async (sql: string) => {
if (/token_version/i.test(sql)) return { rows: [{ token_version: 1 }] };
if (/SELECT role FROM users/i.test(sql)) return { rows: [{ role: "admin" }] };
if (/SELECT COUNT\(\*\) FROM users/i.test(sql)) return { rows: [{ count: String(total) }] };
if (/FROM page_users pu/i.test(sql)) return { rows: pageRows };
throw new Error(`unexpected query: ${sql.replace(/\s+/g, " ").slice(0, 80)}`);
});
}

async function listUsers(n: number, limit: number, total: number, page = 1) {
mockQuery.mockReset();
wireDb(makeUsers(n), total);
const res = await request(app)
.get(`/api/v1/admin/users?page=${page}&limit=${limit}`)
.set("Authorization", `Bearer ${adminToken}`);
return { res, queries: mockQuery.mock.calls.length };
}

it("issues the same number of queries for 1 user and for 50 users", async () => {
const small = await listUsers(1, 1, 1);
const large = await listUsers(50, 50, 50);

expect(small.res.status).toBe(200);
expect(large.res.status).toBe(200);
expect(large.res.body.data).toHaveLength(50);
// auth (2) + page query (1) + total count (1)
expect(small.queries).toBe(4);
expect(large.queries).toBe(small.queries);
});

it("returns an empty page with the same query count", async () => {
const { res, queries } = await listUsers(0, 20, 0);

expect(res.status).toBe(200);
expect(res.body).toEqual({
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
});
expect(queries).toBe(4);
});

it("keeps the response shape and pagination metadata unchanged", async () => {
const { res } = await listUsers(2, 2, 45, 3);

expect(res.status).toBe(200);
expect(res.body.pagination).toEqual({ page: 3, limit: 2, total: 45, totalPages: 23 });
expect(Object.keys(res.body.data[0]).sort()).toEqual(
["balance", "id", "phone", "registrationDate", "tradeCount"]
);
// a user with no transactions or trades still comes back with zero values
expect(res.body.data[0]).toMatchObject({ balance: 0, tradeCount: 0 });
});

it("scopes the aggregates to the requested page and passes limit/offset", async () => {
await listUsers(2, 2, 45, 3);

const pageCall = mockQuery.mock.calls.find(([sql]) => /FROM page_users pu/i.test(sql));
expect(pageCall).toBeDefined();
const [sql, params] = pageCall as [string, unknown[]];
expect(params).toEqual([2, 4]);
expect(sql).toMatch(/WITH page_users AS/i);
expect(sql).toMatch(/FROM transactions\s+WHERE user_id IN \(SELECT id FROM page_users\)/i);
expect(sql).toMatch(/WHERE seller_id IN \(SELECT id FROM page_users\)/i);
expect(sql).toMatch(/WHERE buyer_id\s+IN \(SELECT id FROM page_users\)/i);
});
});
40 changes: 29 additions & 11 deletions server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,16 @@ router.post(
* from O(n) correlated lookups to O(1) sequential scans regardless of page size.
* `transactions(user_id)` is indexed by migration 010 (added alongside this
* change) so the aggregation scan uses the index rather than a full table scan.
*
* Issue #480: the grouped subqueries above still aggregated *every* row in
* `transactions` and `trade_offers` on each request, so latency grew with
* total platform activity even though only one page of users is returned.
* The page is now selected first (`page_users` CTE) and both aggregates are
* restricted to that page with a batched `IN (SELECT id FROM page_users)`,
* which lets Postgres use the user_id / seller_id / buyer_id indexes from
* migration 010. The handler still issues exactly two queries per request
* (page + total count) regardless of page size, and the response shape and
* ordering are unchanged.
*/
router.get(
"/users",
Expand All @@ -362,31 +372,39 @@ router.get(
balance: number;
tradeCount: number;
}>(
`SELECT
u.id,
u.phone,
u.created_at AS "registrationDate",
`WITH page_users AS (
SELECT id, phone, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
)
SELECT
pu.id,
pu.phone,
pu.created_at AS "registrationDate",
COALESCE(tx_agg.net_balance, 0)::float8 AS balance,
COALESCE(tr_agg.trade_count, 0)::int AS "tradeCount"
FROM users u
FROM page_users pu
LEFT JOIN (
SELECT
user_id,
SUM(CASE WHEN direction = 'credit' THEN amount ELSE -amount END) AS net_balance
FROM transactions
WHERE user_id IN (SELECT id FROM page_users)
GROUP BY user_id
) tx_agg ON tx_agg.user_id = u.id
) tx_agg ON tx_agg.user_id = pu.id
LEFT JOIN (
SELECT participant_id, COUNT(*) AS trade_count
FROM (
SELECT seller_id AS participant_id FROM trade_offers WHERE seller_id IS NOT NULL
SELECT seller_id AS participant_id FROM trade_offers
WHERE seller_id IN (SELECT id FROM page_users)
UNION ALL
SELECT buyer_id AS participant_id FROM trade_offers WHERE buyer_id IS NOT NULL
SELECT buyer_id AS participant_id FROM trade_offers
WHERE buyer_id IN (SELECT id FROM page_users)
) participation
GROUP BY participant_id
) tr_agg ON tr_agg.participant_id = u.id
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2`,
) tr_agg ON tr_agg.participant_id = pu.id
ORDER BY pu.created_at DESC`,
[limit, offset]
);

Expand Down