stack/apps/e2e/tests/backend/endpoints/api/v1/internal-user-activity.test.ts
Mantra e93b7520c4
feat(analytics): add route analytics heatmaps (#1520)
## Summary

Adds route analytics heatmaps, stacked on top of
`codex/analytics-overview-filters` (#1496).

- Heatmap API routes (`/analytics/heatmap`, internal heatmap +
heatmap-token endpoints)
- Signed heatmap token signing/verification lib + tests
- Dashboard heatmaps page (client + route)
- Dev-tool + event-tracker support for heatmap capture
- ClickHouse migration support

## Demo


https://app.devin.ai/attachments/49cd6a96-8962-46d9-b8fb-145746cc6dee/rec-c80ec66f-21a3-49fb-bfae-19195ce7b930-edited.mp4

## Notes
Base branch is `codex/analytics-overview-filters` so the diff shows only
the heatmap changes. Will retarget to `dev` once the base PR lands.

Link to Devin session:
https://app.devin.ai/sessions/16f8adac29b948b38280c85418617fea
Requested by: @mantrakp04

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added clickmap overlay to analytics dashboard, enabling visual click
heatmap analysis on live websites.
* Enhanced analytics metrics with hourly breakdowns, bounce rates, and
top regions/browsers/devices filtering.

* **Bug Fixes**
  * Improved click event tracking accuracy and dead-click detection.
  * Fixed overlay z-index stacking for better visibility.

* **Style**
* Updated dashboard card padding and navigation button styling for
consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 12:06:16 -07:00

92 lines
2.8 KiB
TypeScript

import { wait } from "@hexclave/shared/dist/utils/promises";
import { randomUUID } from "node:crypto";
import { it } from "../../../../helpers";
import { Auth, Project, niceBackendFetch } from "../../../backend-helpers";
// Matches USER_ACTIVITY_WINDOW_DAYS in
// apps/backend/src/app/api/latest/internal/user-activity/route.tsx.
// When that constant changes, bump this one too.
const USER_ACTIVITY_WINDOW_DAYS = 22 * 16;
it("should return an empty activity clickmap for an unknown user", async ({ expect }) => {
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
},
});
const response = await niceBackendFetch(
`/api/v1/internal/user-activity?user_id=${randomUUID()}`,
{ accessType: "admin" },
);
expect(response.status).toBe(200);
expect(Array.isArray(response.body.data_points)).toBe(true);
expect(response.body.data_points).toHaveLength(USER_ACTIVITY_WINDOW_DAYS);
for (const point of response.body.data_points) {
expect(typeof point.date).toBe("string");
expect(point.activity).toBe(0);
}
});
it("should record activity for a real user that has signed in", async ({ expect }) => {
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
},
});
const { userId } = await Auth.Otp.signIn();
// ClickHouse ingestion is async; poll until the signed-in user shows up.
let totalActivity = 0;
for (let i = 0; i < 15; i += 1) {
const response = await niceBackendFetch(
`/api/v1/internal/user-activity?user_id=${userId}`,
{ accessType: "admin" },
);
expect(response.status).toBe(200);
expect(response.body.data_points).toHaveLength(USER_ACTIVITY_WINDOW_DAYS);
totalActivity = response.body.data_points.reduce(
(sum: number, point: { activity: number }) => sum + point.activity,
0,
);
if (totalActivity > 0) break;
await wait(2_000);
}
expect(totalActivity).toBeGreaterThan(0);
}, {
timeout: 60_000,
});
it("should reject non-admin callers", async ({ expect }) => {
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
},
});
const response = await niceBackendFetch(
`/api/v1/internal/user-activity?user_id=${randomUUID()}`,
{ accessType: "server" },
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "INSUFFICIENT_ACCESS_TYPE",
"details": {
"actual_access_type": "server",
"allowed_access_types": ["admin"],
},
"error": "The x-hexclave-access-type header must be 'admin', but was 'server'. (The legacy x-stack-access-type header is also accepted.)",
},
"headers": Headers {
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
<some fields may have been hidden>,
},
}
`);
});