diff --git a/web/api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.generated.ts b/web/api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.generated.ts new file mode 100644 index 000000000..b6220b9c8 --- /dev/null +++ b/web/api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.generated.ts @@ -0,0 +1,238 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type GetWorldIdAnalyticsScopeQueryVariables = Types.Exact<{ + app_id: Types.Scalars["String"]["input"]; + action_ids: + | Array + | Types.Scalars["String"]["input"]; +}>; + +export type GetWorldIdAnalyticsScopeQuery = { + __typename?: "query_root"; + app: Array<{ + __typename?: "app"; + id: string; + created_at: string; + is_staging: boolean; + rp_registration: Array<{ + __typename?: "rp_registration"; + rp_id: string; + created_at: string; + }>; + }>; + legacy_actions: Array<{ + __typename?: "action"; + id: string; + app_id: string; + created_at: string; + }>; + actions: Array<{ + __typename?: "action_v4"; + id: string; + rp_id: string; + environment: unknown; + created_at: string; + }>; + has_legacy_history: Array<{ + __typename?: "action_legacy_stats_daily"; + date_utc: string; + }>; +}; + +export type GetWorldIdAnalyticsAppDailyQueryVariables = Types.Exact<{ + app_id: Types.Scalars["String"]["input"]; + environment: Types.Scalars["String"]["input"]; + from: Types.Scalars["date"]["input"]; + through: Types.Scalars["date"]["input"]; +}>; + +export type GetWorldIdAnalyticsAppDailyQuery = { + __typename?: "query_root"; + world_id_app_stats_daily: Array<{ + __typename?: "world_id_app_stats_daily"; + date_utc: string; + unique_count: number; + }>; +}; + +export type GetWorldIdAnalyticsActionDailyQueryVariables = Types.Exact<{ + legacy_ids: + | Array + | Types.Scalars["String"]["input"]; + action_ids: + | Array + | Types.Scalars["String"]["input"]; + from: Types.Scalars["date"]["input"]; + through: Types.Scalars["date"]["input"]; +}>; + +export type GetWorldIdAnalyticsActionDailyQuery = { + __typename?: "query_root"; + action_legacy_stats_daily: Array<{ + __typename?: "action_legacy_stats_daily"; + action_id: string; + date_utc: string; + unique_count: number; + }>; + action_v4_stats_daily: Array<{ + __typename?: "action_v4_stats_daily"; + action_v4_id: string; + date_utc: string; + unique_count: number; + }>; +}; + +export const GetWorldIdAnalyticsScopeDocument = gql` + query GetWorldIdAnalyticsScope($app_id: String!, $action_ids: [String!]!) { + app(where: { id: { _eq: $app_id }, deleted_at: { _is_null: true } }) { + id + created_at + is_staging + rp_registration { + rp_id + created_at + } + } + legacy_actions: action(where: { id: { _in: $action_ids } }) { + id + app_id + created_at + } + actions: action_v4(where: { id: { _in: $action_ids } }) { + id + rp_id + environment + created_at + } + has_legacy_history: action_legacy_stats_daily( + limit: 1 + where: { action: { app_id: { _eq: $app_id } } } + ) { + date_utc + } + } +`; +export const GetWorldIdAnalyticsAppDailyDocument = gql` + query GetWorldIdAnalyticsAppDaily( + $app_id: String! + $environment: String! + $from: date! + $through: date! + ) { + world_id_app_stats_daily: world_id_analytics_app_daily( + args: { + app_id_input: $app_id + environment_input: $environment + from_date_input: $from + through_date_input: $through + } + ) { + date_utc + unique_count + } + } +`; +export const GetWorldIdAnalyticsActionDailyDocument = gql` + query GetWorldIdAnalyticsActionDaily( + $legacy_ids: [String!]! + $action_ids: [String!]! + $from: date! + $through: date! + ) { + action_legacy_stats_daily( + where: { + action_id: { _in: $legacy_ids } + date_utc: { _gte: $from, _lte: $through } + } + ) { + action_id + date_utc + unique_count + } + action_v4_stats_daily( + where: { + action_v4_id: { _in: $action_ids } + date_utc: { _gte: $from, _lte: $through } + } + ) { + action_v4_id + date_utc + unique_count + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + GetWorldIdAnalyticsScope( + variables: GetWorldIdAnalyticsScopeQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + GetWorldIdAnalyticsScopeDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "GetWorldIdAnalyticsScope", + "query", + variables, + ); + }, + GetWorldIdAnalyticsAppDaily( + variables: GetWorldIdAnalyticsAppDailyQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + GetWorldIdAnalyticsAppDailyDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "GetWorldIdAnalyticsAppDaily", + "query", + variables, + ); + }, + GetWorldIdAnalyticsActionDaily( + variables: GetWorldIdAnalyticsActionDailyQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + GetWorldIdAnalyticsActionDailyDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "GetWorldIdAnalyticsActionDaily", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.graphql b/web/api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.graphql new file mode 100644 index 000000000..863bb46bc --- /dev/null +++ b/web/api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.graphql @@ -0,0 +1,75 @@ +query GetWorldIdAnalyticsScope($app_id: String!, $action_ids: [String!]!) { + app(where: { id: { _eq: $app_id }, deleted_at: { _is_null: true } }) { + id + created_at + is_staging + rp_registration { + rp_id + created_at + } + } + legacy_actions: action(where: { id: { _in: $action_ids } }) { + id + app_id + created_at + } + actions: action_v4(where: { id: { _in: $action_ids } }) { + id + rp_id + environment + created_at + } + has_legacy_history: action_legacy_stats_daily( + limit: 1 + where: { action: { app_id: { _eq: $app_id } } } + ) { + date_utc + } +} + +query GetWorldIdAnalyticsAppDaily( + $app_id: String! + $environment: String! + $from: date! + $through: date! +) { + world_id_app_stats_daily: world_id_analytics_app_daily( + args: { + app_id_input: $app_id + environment_input: $environment + from_date_input: $from + through_date_input: $through + } + ) { + date_utc + unique_count + } +} + +query GetWorldIdAnalyticsActionDaily( + $legacy_ids: [String!]! + $action_ids: [String!]! + $from: date! + $through: date! +) { + action_legacy_stats_daily( + where: { + action_id: { _in: $legacy_ids } + date_utc: { _gte: $from, _lte: $through } + } + ) { + action_id + date_utc + unique_count + } + action_v4_stats_daily( + where: { + action_v4_id: { _in: $action_ids } + date_utc: { _gte: $from, _lte: $through } + } + ) { + action_v4_id + date_utc + unique_count + } +} diff --git a/web/api/portal/apps/[app_id]/world-id-analytics/index.ts b/web/api/portal/apps/[app_id]/world-id-analytics/index.ts new file mode 100644 index 000000000..509145b87 --- /dev/null +++ b/web/api/portal/apps/[app_id]/world-id-analytics/index.ts @@ -0,0 +1,198 @@ +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { getIsUserAllowedToReadApp } from "@/lib/permissions"; +import { logger } from "@/lib/logger"; +import { NextRequest, NextResponse } from "next/server"; +import { getSdk } from "./graphql/get-world-id-analytics.generated"; + +const DAY_MS = 86_400_000; +type Period = "last_7_days" | "all_time"; +type Environment = "staging" | "production"; +type Row = { date_utc: string; unique_count: string | number }; +type ActionRow = { id: string; created_at: string }; + +const utcDate = (value: string | Date) => + (typeof value === "string" ? new Date(value) : value) + .toISOString() + .slice(0, 10); +const dayNumber = (date: string) => + Date.parse(`${date}T00:00:00.000Z`) / DAY_MS; +const dateFromDay = (day: number) => + new Date(day * DAY_MS).toISOString().slice(0, 10); +const laterDate = (left: string, right: string) => + dayNumber(left) > dayNumber(right) ? left : right; + +function series(rows: Row[], from: string, through: string) { + const counts = new Map( + rows.map((row) => [row.date_utc, BigInt(row.unique_count)]), + ); + const points = []; + let total = 0n; + for (let day = dayNumber(from); day <= dayNumber(through); day += 1) { + const date = dateFromDay(day); + const count = counts.get(date) ?? 0n; + total += count; + points.push({ date, count: count.toString() }); + } + return { count: total.toString(), series: points }; +} + +const badRequest = () => + NextResponse.json({ error: "Invalid analytics request" }, { status: 400 }); + +export async function GET( + request: NextRequest, + context: { params: Promise<{ app_id: string }> }, +) { + const { app_id: appId } = await context.params; + if (!(await getIsUserAllowedToReadApp(appId))) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const environment = request.nextUrl.searchParams.get( + "environment", + ) as Environment | null; + const period = request.nextUrl.searchParams.get("period") as Period | null; + const rawIds = request.nextUrl.searchParams.get("action_ids"); + if ( + !environment || + !["staging", "production"].includes(environment) || + !period || + !["last_7_days", "all_time"].includes(period) + ) { + return badRequest(); + } + const requestedIds = rawIds === null ? [] : rawIds.split(","); + if ( + requestedIds.length > 12 || + requestedIds.some((id) => !id) || + new Set(requestedIds).size !== requestedIds.length + ) { + return badRequest(); + } + + try { + const client = await getAPIServiceGraphqlClient(); + const sdk = getSdk(client); + const scope = await sdk.GetWorldIdAnalyticsScope({ + app_id: appId, + action_ids: requestedIds, + }); + const app = scope.app[0]; + if (!app) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const rpIds = new Set(app.rp_registration.map((rp: any) => rp.rp_id)); + const legacyById = new Map( + scope.legacy_actions.map((action: any) => [action.id, action]), + ); + const v4ById = new Map( + scope.actions.map((action: any) => [action.id, action]), + ); + for (const id of requestedIds) { + const legacy = legacyById.get(id); + const v4 = v4ById.get(id); + const expectsV4 = id.startsWith("action_v4_"); + const legacyEnvironment = app.is_staging ? "staging" : "production"; + if ( + Boolean(legacy) === Boolean(v4) || + (expectsV4 ? !v4 : !legacy) || + (legacy && + (legacy.app_id !== appId || legacyEnvironment !== environment)) || + (v4 && (!rpIds.has(v4.rp_id) || v4.environment !== environment)) + ) { + return badRequest(); + } + } + + const today = utcDate(new Date()); + const weekStart = dateFromDay(dayNumber(today) - 6); + const appCreated = utcDate(app.created_at); + const hasLegacyHistory = + scope.has_legacy_history.length > 0 && + (app.is_staging ? "staging" : "production") === environment; + const earliestRp = app.rp_registration + .map((rp: any) => utcDate(rp.created_at)) + .sort()[0]; + const allTimeAppStart = hasLegacyHistory + ? appCreated + : earliestRp ?? appCreated; + const appFrom = + period === "last_7_days" + ? laterDate(weekStart, appCreated) + : allTimeAppStart; + const actionRows: ActionRow[] = [ + ...requestedIds + .filter((id) => legacyById.has(id)) + .map((id) => legacyById.get(id)), + ...requestedIds + .filter((id) => v4ById.has(id)) + .map((id) => v4ById.get(id)), + ]; + const actionFrom = actionRows.length + ? actionRows + .map((action) => + period === "last_7_days" + ? laterDate(weekStart, utcDate(action.created_at)) + : utcDate(action.created_at), + ) + .sort()[0] + : appFrom; + const readFrom = [appFrom, actionFrom].sort()[0]; + + const appResult = await sdk.GetWorldIdAnalyticsAppDaily({ + app_id: appId, + environment, + from: appFrom, + through: today, + }); + const actionResult = requestedIds.length + ? await sdk.GetWorldIdAnalyticsActionDaily({ + legacy_ids: requestedIds.filter((id) => legacyById.has(id)), + action_ids: requestedIds.filter((id) => v4ById.has(id)), + from: readFrom, + through: today, + }) + : { action_legacy_stats_daily: [], action_v4_stats_daily: [] }; + + const metricFor = (action: any, rows: Row[]) => { + const created = utcDate(action.created_at); + const from = + period === "last_7_days" ? laterDate(weekStart, created) : created; + return { id: action.id, ...series(rows, from, today) }; + }; + const legacyActions = requestedIds + .filter((id) => legacyById.has(id)) + .map((id) => { + const action = legacyById.get(id); + return metricFor( + action, + actionResult.action_legacy_stats_daily.filter( + (row: any) => row.action_id === id, + ), + ); + }); + const actions = requestedIds + .filter((id) => v4ById.has(id)) + .map((id) => { + const action = v4ById.get(id); + return metricFor( + action, + actionResult.action_v4_stats_daily.filter( + (row: any) => row.action_v4_id === id, + ), + ); + }); + + return NextResponse.json({ + period, + app: series(appResult.world_id_app_stats_daily, appFrom, today), + legacy_actions: legacyActions, + actions, + }); + } catch (error) { + logger.error("Failed to read World ID analytics", { error, appId }); + return NextResponse.json( + { error: "Analytics unavailable" }, + { status: 500 }, + ); + } +} diff --git a/web/app/api/portal/apps/[app_id]/world-id-analytics/route.ts b/web/app/api/portal/apps/[app_id]/world-id-analytics/route.ts new file mode 100644 index 000000000..91f0611d2 --- /dev/null +++ b/web/app/api/portal/apps/[app_id]/world-id-analytics/route.ts @@ -0,0 +1 @@ +export { GET } from "@/api/portal/apps/[app_id]/world-id-analytics"; diff --git a/web/package.json b/web/package.json index bf136504d..f2f97f675 100644 --- a/web/package.json +++ b/web/package.json @@ -172,6 +172,7 @@ "test:integration": "node tests/verifyHasuraIsUp.js && jest tests/integration --runInBand", "test:integration:server": "docker compose -f ../docker-compose-test.yaml up --abort-on-container-exit", "test:world-id-analytics:fresh": "bash tests/world-id-analytics/run-fresh-stack.sh", + "test:world-id-analytics:million": "bash tests/world-id-analytics/run-fresh-stack.sh --million", "test:unit": "jest tests/unit lib/schema --maxWorkers=2", "typecheck": "tsc" }, diff --git a/web/tests/api/portal/world-id-analytics.test.ts b/web/tests/api/portal/world-id-analytics.test.ts new file mode 100644 index 000000000..f29ed501a --- /dev/null +++ b/web/tests/api/portal/world-id-analytics.test.ts @@ -0,0 +1,520 @@ +import { GET } from "@/api/portal/apps/[app_id]/world-id-analytics"; +import { NextRequest } from "next/server"; +import { + makeActionDailyResult, + makeAnalyticsScopeResult, + makeAppDailyResult, + type AnalyticsScopeInput, +} from "../../contracts/world-id-analytics-graphql"; +import { normalizeAnalyticsResponse } from "../../contracts/world-id-analytics-endpoint"; + +// #region Mocks +const getIsUserAllowedToReadApp = jest.fn(); +const databaseOperation = jest.fn(); +const scopeRead = jest.fn(); +const appDailyRead = jest.fn(); +const actionDailyRead = jest.fn(); + +jest.mock("@/lib/logger", () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock("@/api/helpers/graphql", () => ({ + getAPIServiceGraphqlClient: jest.fn().mockResolvedValue({}), +})); + +jest.mock("@/lib/permissions", () => ({ + getIsUserAllowedToReadApp: (...args: unknown[]) => + getIsUserAllowedToReadApp(...args), +})); + +jest.mock( + "../../../api/portal/apps/[app_id]/world-id-analytics/graphql/get-world-id-analytics.generated", + () => ({ + getSdk: () => + new Proxy( + {}, + { + get: () => databaseOperation, + }, + ), + }), + { virtual: true }, +); +// #endregion + +// #region Test Data +const appId = "app_00000000000000000000000000000001"; +const otherAppId = "app_00000000000000000000000000000002"; +const legacyActionId = "action_000000000000000000000000000001"; +const v4ActionId = "action_v4_0000000000000000000000000001"; + +const makeRequest = (query = "environment=production&period=last_7_days") => + new NextRequest( + `http://localhost:3000/api/portal/apps/${appId}/world-id-analytics?${query}`, + ); + +const context = { params: Promise.resolve({ app_id: appId }) }; + +const call = (query?: string) => GET(makeRequest(query), context); + +const setSuccessfulReads = (input: AnalyticsScopeInput = {}) => { + scopeRead.mockResolvedValue(makeAnalyticsScopeResult(appId, input)); + appDailyRead.mockResolvedValue(makeAppDailyResult()); + actionDailyRead.mockResolvedValue(makeActionDailyResult()); +}; + +const bodyOf = async (response: Response) => + normalizeAnalyticsResponse(await response.json()); +// #endregion + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-07-30T12:00:00.000Z")); + getIsUserAllowedToReadApp.mockResolvedValue(true); + setSuccessfulReads(); + databaseOperation.mockImplementation((variables: unknown) => { + if (databaseOperation.mock.calls.length === 1) { + return scopeRead(variables); + } + if ( + variables && + typeof variables === "object" && + Object.values(variables).some(Array.isArray) + ) { + return actionDailyRead(variables); + } + return appDailyRead(variables); + }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +// #region Authorization and validation +describe("GET world-id-analytics [authorization and validation]", () => { + it("returns 404 on authorization denial without reading analytics", async () => { + getIsUserAllowedToReadApp.mockResolvedValue(false); + + const response = await call(); + + expect(response.status).toBe(404); + expect(databaseOperation).not.toHaveBeenCalled(); + }); + + it("returns 404 for a soft-deleted or absent app", async () => { + setSuccessfulReads({ app: null }); + + const response = await call(); + + expect(response.status).toBe(404); + expect(databaseOperation).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["missing environment", "period=last_7_days"], + ["unknown environment", "environment=preview&period=last_7_days"], + ["missing period", "environment=production"], + ["unknown period", "environment=production&period=yesterday"], + [ + "an empty action id", + "environment=production&period=last_7_days&action_ids=action_1,,action_2", + ], + [ + "a duplicate action id", + `environment=production&period=last_7_days&action_ids=${legacyActionId},${legacyActionId}`, + ], + [ + "more than twelve action ids", + `environment=production&period=last_7_days&action_ids=${Array.from( + { length: 13 }, + (_, index) => `action_${index.toString().padStart(32, "0")}`, + ).join(",")}`, + ], + ])("rejects %s before any analytics read", async (_name, query) => { + const response = await call(query); + + expect(response.status).toBe(400); + expect(databaseOperation).not.toHaveBeenCalled(); + }); +}); +// #endregion + +// #region Requested action ownership +describe("GET world-id-analytics [requested action ownership]", () => { + it.each([ + [ + "unknown", + "action_000000000000000000000000000099", + { legacyActions: [], actions: [] }, + ], + [ + "another app", + legacyActionId, + { + legacyActions: [ + { id: legacyActionId, app_id: otherAppId, created_at: "2026-07-20" }, + ], + }, + ], + [ + "another environment", + v4ActionId, + { + actions: [ + { + id: v4ActionId, + app_id: appId, + environment: "staging", + created_at: "2026-07-20", + }, + ], + }, + ], + [ + "another app's RP registration", + v4ActionId, + { + actions: [ + { + id: v4ActionId, + app_id: otherAppId, + environment: "production", + created_at: "2026-07-20", + }, + ], + }, + ], + ] as const)( + "rejects an action from %s", + async (_case, requestedId, scope) => { + setSuccessfulReads(scope); + + const response = await call( + `environment=production&period=last_7_days&action_ids=${requestedId}`, + ); + + expect(response.status).toBe(400); + expect(appDailyRead).not.toHaveBeenCalled(); + }, + ); + + it("rejects an id resolved from the wrong source table", async () => { + setSuccessfulReads({ + legacyActions: [], + actions: [ + { + id: legacyActionId, + app_id: appId, + environment: "production", + created_at: "2026-07-20", + }, + ], + }); + + const response = await call( + `environment=production&period=last_7_days&action_ids=${legacyActionId}`, + ); + + expect(response.status).toBe(400); + expect(appDailyRead).not.toHaveBeenCalled(); + }); + + it("rejects a legacy action when the app maps to staging", async () => { + setSuccessfulReads({ + app: { is_staging: true }, + legacyActions: [ + { + id: legacyActionId, + app_id: appId, + created_at: "2026-07-20", + }, + ], + }); + + const response = await call( + `environment=production&period=last_7_days&action_ids=${legacyActionId}`, + ); + + expect(response.status).toBe(400); + }); + + it("rejects an id that resolves ambiguously in both sources", async () => { + setSuccessfulReads({ + legacyActions: [ + { + id: legacyActionId, + app_id: appId, + created_at: "2026-07-20", + }, + ], + actions: [ + { + id: legacyActionId, + app_id: appId, + environment: "production", + created_at: "2026-07-20", + }, + ], + }); + + expect( + ( + await call( + `environment=production&period=last_7_days&action_ids=${legacyActionId}`, + ) + ).status, + ).toBe(400); + }); +}); +// #endregion + +// #region Last 7 Days behavior +describe("GET world-id-analytics [Last 7 Days]", () => { + it("zero-fills from a young app creation date and sums database rows", async () => { + setSuccessfulReads({ app: { created_at: "2026-07-28T23:59:59.000Z" } }); + appDailyRead.mockResolvedValue( + makeAppDailyResult([ + { date_utc: "2026-07-28", unique_count: "2" }, + { date_utc: "2026-07-30", unique_count: "5" }, + ]), + ); + + const response = await call(); + const body = await bodyOf(response); + + expect(response.status).toBe(200); + expect(body.app).toEqual({ + count: "7", + series: [ + { date: "2026-07-28", count: "2" }, + { date: "2026-07-29", count: "0" }, + { date: "2026-07-30", count: "5" }, + ], + }); + }); + + it("returns only the requested source-specific action blocks", async () => { + setSuccessfulReads({ + legacyActions: [ + { + id: legacyActionId, + app_id: appId, + created_at: "2026-07-28T23:30:00.000Z", + }, + ], + actions: [ + { + id: v4ActionId, + app_id: appId, + environment: "production", + created_at: "2026-07-27T01:00:00.000Z", + }, + ], + }); + appDailyRead.mockResolvedValue( + makeAppDailyResult([{ date_utc: "2026-07-30", unique_count: "20" }]), + ); + actionDailyRead.mockResolvedValue( + makeActionDailyResult({ + legacy: [ + { + action_id: legacyActionId, + date_utc: "2026-07-29", + unique_count: "2", + }, + ], + v4: [ + { + action_v4_id: v4ActionId, + date_utc: "2026-07-30", + unique_count: "3", + }, + ], + }), + ); + + const body = await bodyOf( + await call( + `environment=production&period=last_7_days&action_ids=${legacyActionId},${v4ActionId}`, + ), + ); + + expect(body.app.count).toBe("20"); + expect(body.legacyActions).toEqual([ + expect.objectContaining({ id: legacyActionId, count: "2" }), + ]); + expect(body.actions).toEqual([ + expect.objectContaining({ id: v4ActionId, count: "3" }), + ]); + expect(body.legacyActions[0].series).toEqual([ + { date: "2026-07-28", count: "0" }, + { date: "2026-07-29", count: "2" }, + { date: "2026-07-30", count: "0" }, + ]); + }); + + it("keeps app aggregation independent of requested action ids", async () => { + setSuccessfulReads({ + legacyActions: [ + { + id: legacyActionId, + app_id: appId, + created_at: "2026-07-20", + }, + ], + }); + appDailyRead.mockResolvedValue( + makeAppDailyResult([{ date_utc: "2026-07-30", unique_count: "42" }]), + ); + actionDailyRead.mockResolvedValue( + makeActionDailyResult({ + legacy: [ + { + action_id: legacyActionId, + date_utc: "2026-07-30", + unique_count: "5", + }, + ], + }), + ); + + const body = await bodyOf( + await call( + `environment=production&period=last_7_days&action_ids=${legacyActionId}`, + ), + ); + + expect(body.app.count).toBe("42"); + expect(body.legacyActions[0].count).toBe("5"); + }); + + it("preserves counts above Number.MAX_SAFE_INTEGER", async () => { + appDailyRead.mockResolvedValue( + makeAppDailyResult([ + { date_utc: "2026-07-29", unique_count: "9007199254740993" }, + { date_utc: "2026-07-30", unique_count: "9" }, + ]), + ); + + const body = await bodyOf(await call()); + + expect(body.app.count).toBe("9007199254741002"); + expect(body.app.series.at(-2)).toEqual({ + date: "2026-07-29", + count: "9007199254740993", + }); + }); + + it("returns a flat seven-day zero series for an old empty scope", async () => { + const body = await bodyOf(await call()); + + expect(body.app.count).toBe("0"); + expect(body.app.series).toHaveLength(7); + expect(body.app.series.every((point) => point.count === "0")).toBe(true); + }); +}); +// #endregion + +// #region All Time behavior +describe("GET world-id-analytics [All Time]", () => { + it("starts at app creation when the environment has legacy history", async () => { + setSuccessfulReads({ + app: { + created_at: "2026-07-20T23:59:59.000Z", + rp_registration: [{ created_at: "2026-07-25T00:00:00.000Z" }], + }, + hasLegacyHistory: true, + }); + + const body = await bodyOf( + await call("environment=production&period=all_time"), + ); + + expect(body.app.series[0].date).toBe("2026-07-20"); + }); + + it("starts a v4-only app at RP registration without pre-RP zeros", async () => { + setSuccessfulReads({ + app: { + created_at: "2026-07-20T00:00:00.000Z", + rp_registration: [{ created_at: "2026-07-25T23:59:59.000Z" }], + }, + hasLegacyHistory: false, + }); + + const body = await bodyOf( + await call("environment=production&period=all_time"), + ); + + expect(body.app.series[0].date).toBe("2026-07-25"); + expect(body.app.series.some((point) => point.date === "2026-07-24")).toBe( + false, + ); + }); + + it("starts each requested action at its own source creation date", async () => { + setSuccessfulReads({ + actions: [ + { + id: v4ActionId, + app_id: appId, + environment: "production", + created_at: "2026-07-29T23:59:59.000Z", + }, + ], + }); + actionDailyRead.mockResolvedValue( + makeActionDailyResult({ + v4: [ + { + action_v4_id: v4ActionId, + date_utc: "2026-07-30", + unique_count: "2", + }, + ], + }), + ); + + const body = await bodyOf( + await call( + `environment=production&period=all_time&action_ids=${v4ActionId}`, + ), + ); + + expect(body.app.series[0].date).toBe("2026-07-20"); + expect(body.actions[0]).toEqual({ + id: v4ActionId, + count: "2", + series: [ + { date: "2026-07-29", count: "0" }, + { date: "2026-07-30", count: "2" }, + ], + }); + }); +}); +// #endregion + +// #region Response exclusions and failures +describe("GET world-id-analytics [response semantics]", () => { + it("does not expose retired metrics or source breakdowns", async () => { + const response = await call(); + const json = JSON.stringify(await response.json()); + + expect(json).not.toMatch( + /session|team|reuse|uses|latest|stale|watermark|human/i, + ); + expect(normalizeAnalyticsResponse(JSON.parse(json)).app.count).toBe("0"); + }); + + it("returns 500 instead of partial data when an analytics read fails", async () => { + appDailyRead.mockRejectedValue(new Error("database unavailable")); + + const response = await call(); + + expect(response.status).toBe(500); + }); +}); +// #endregion diff --git a/web/tests/world-id-analytics/integration.test.ts b/web/tests/world-id-analytics/integration.test.ts new file mode 100644 index 000000000..fc23aaeff --- /dev/null +++ b/web/tests/world-id-analytics/integration.test.ts @@ -0,0 +1,1139 @@ +import { POST as rollupWorldIdAnalytics } from "@/api/_rollup-world-id-analytics"; +import { GET as getWorldIdAnalytics } from "@/api/portal/apps/[app_id]/world-id-analytics"; +import { generateServiceJWT, generateUserJWT } from "@/api/helpers/jwts"; +import { NextRequest } from "next/server"; +import { Pool, type PoolClient } from "pg"; +import { normalizeAnalyticsResponse } from "../contracts/world-id-analytics-endpoint"; +import { + readCanonicalSourceDaily, + readRolledSourceDaily, + toAppDailyRows, + toLifetimeRows, +} from "./canonical-analytics"; +import { + fixture, + insertV3Nullifier, + insertV4Nullifier, + resetFixture, + seedFixture, +} from "./fresh-stack-fixture"; + +// #region Mocks +const getIsUserAllowedToReadApp = jest.fn(); + +jest.mock("@/lib/logger", () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +// This is the sole mocked application boundary. GraphQL clients, generated +// operations, PostgreSQL, migrations, and Hasura metadata remain real. +jest.mock("@/lib/permissions", () => ({ + getIsUserAllowedToReadApp: (...args: unknown[]) => + getIsUserAllowedToReadApp(...args), +})); +// #endregion + +// #region Test Data +const pool = new Pool({ max: 12 }); +const barrierLock: [number, number] = [812_404, 71]; +const rollupLock: [number, number] = [533_214, 43]; + +type DailyRow = { + action_id?: string; + action_v4_id?: string; + date_utc: string; + unique_count: string; +}; + +const callRollup = async (body?: { + chunk_days?: number; + from_date: string; + to_date: string; +}) => { + const response = await rollupWorldIdAnalytics( + new NextRequest("http://localhost:3000/api/_rollup-world-id-analytics", { + method: "POST", + headers: { + authorization: process.env.INTERNAL_ENDPOINTS_SECRET as string, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + const responseBody = await response.json(); + if (!response.ok) { + throw new Error( + `rollup failed (${response.status}): ${JSON.stringify(responseBody)}`, + ); + } + return responseBody; +}; + +const expectDatedRollup = async (fromDate: string, toDate: string) => { + await expect( + callRollup({ from_date: fromDate, to_date: toDate }), + ).resolves.toEqual({ success: true, chunks: 1, failed_ranges: [] }); +}; + +const callEndpoint = async (input?: { + actionIds?: string[]; + appId?: string; + environment?: "production" | "staging"; + period?: "all_time" | "last_7_days"; +}) => { + const appId = input?.appId ?? fixture.productionAppId; + const search = new URLSearchParams({ + environment: input?.environment ?? "production", + period: input?.period ?? "all_time", + }); + if (input?.actionIds) { + search.set("action_ids", input.actionIds.join(",")); + } + return getWorldIdAnalytics( + new NextRequest( + `http://localhost:3000/api/portal/apps/${appId}/world-id-analytics?${search}`, + ), + { params: Promise.resolve({ app_id: appId }) }, + ); +}; + +const endpointBody = async (input?: Parameters[0]) => { + const response = await callEndpoint(input); + expect(response.status).toBe(200); + return normalizeAnalyticsResponse(await response.json()); +}; + +const getDailyRows = async ( + table: "action_legacy_stats_daily" | "action_v4_stats_daily", + actionId: string, +) => { + const column = + table === "action_legacy_stats_daily" ? "action_id" : "action_v4_id"; + const result = await pool.query( + `SELECT ${column}, date_utc::text, unique_count::text + FROM public.${table} + WHERE ${column} = $1 + ORDER BY date_utc`, + [actionId], + ); + return result.rows; +}; + +const pointMap = (series: Array<{ count: string; date: string }>) => + new Map(series.map((point) => [point.date, point.count])); + +const installPauseTrigger = async () => { + await pool.query( + `CREATE FUNCTION public.contract_pause_analytics_rollup() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(${barrierLock[0]}, ${barrierLock[1]}); + RETURN NEW; + END + $$`, + ); + await pool.query( + `CREATE TRIGGER contract_pause_analytics_rollup + BEFORE INSERT ON public.action_legacy_stats_daily + FOR EACH ROW EXECUTE FUNCTION public.contract_pause_analytics_rollup()`, + ); +}; + +const waitForAdvisoryWaiter = async (lock: [number, number]) => { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + const result = await pool.query<{ waiting: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_locks + WHERE locktype = 'advisory' + AND classid::bigint = $1 + AND objid::bigint = $2 + AND NOT granted + ) AS waiting`, + lock, + ); + if (result.rows[0].waiting) return; + } + throw new Error("rollup never reached deterministic advisory-lock barrier"); +}; + +const releaseBarrier = async (client: PoolClient) => { + await client.query("SELECT pg_advisory_unlock($1, $2)", barrierLock); +}; + +const graphRequest = async (input: { + headers?: Record; + query: string; +}) => { + const response = await fetch( + process.env.NEXT_PUBLIC_GRAPHQL_API_URL as string, + { + method: "POST", + headers: { "content-type": "application/json", ...input.headers }, + body: JSON.stringify({ query: input.query }), + }, + ); + return { + status: response.status, + body: (await response.json()) as { + data?: Record; + errors?: Array<{ message: string }>; + }, + }; +}; + +const readRoleSchema = async (headers?: Record) => { + const response = await graphRequest({ + headers, + query: `query AnalyticsRoleSchema { + __schema { + queryType { fields { name } } + mutationType { fields { name } } + types { name } + } + }`, + }); + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + const schema = response.body.data?.__schema as + | { + mutationType?: { fields: Array<{ name: string }> } | null; + queryType: { fields: Array<{ name: string }> }; + types: Array<{ name: string }>; + } + | undefined; + expect(schema).toBeDefined(); + return { + roots: new Set([ + ...(schema?.queryType.fields.map((field) => field.name) ?? []), + ...(schema?.mutationType?.fields.map((field) => field.name) ?? []), + ]), + types: new Set(schema?.types.map((type) => type.name) ?? []), + }; +}; +// #endregion + +beforeAll(async () => { + if (process.env.WIA_FRESH_STACK !== "true") { + throw new Error( + "Run through pnpm test:world-id-analytics:fresh; shared stacks are forbidden", + ); + } + await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements"); +}); + +beforeEach(async () => { + getIsUserAllowedToReadApp.mockReset(); + getIsUserAllowedToReadApp.mockResolvedValue(true); + await pool.query(` + DO $$ + BEGIN + IF to_regclass('public.action_legacy_stats_daily') IS NOT NULL THEN + DROP TRIGGER IF EXISTS contract_pause_analytics_rollup + ON public.action_legacy_stats_daily; + END IF; + IF to_regclass('public.action_v4_stats_daily') IS NOT NULL THEN + DROP TRIGGER IF EXISTS contract_fail_v4_analytics_rollup + ON public.action_v4_stats_daily; + END IF; + DROP FUNCTION IF EXISTS public.contract_pause_analytics_rollup(); + DROP FUNCTION IF EXISTS public.contract_fail_v4_analytics_rollup(); + END + $$; + `); + await resetFixture(pool); + await seedFixture(pool); +}); + +afterAll(async () => { + await resetFixture(pool); + await pool.end(); +}); + +// #region Cold backfill, canonical parity, and endpoint payload +describe("World ID analytics [real dated backfill and endpoint]", () => { + it("counts canonical v3/v4 rows once by UTC, sums apps, and isolates environments", async () => { + await pool.query("SET TIME ZONE 'America/Los_Angeles'"); + await insertV3Nullifier(pool, { + id: "nil_contract_utc_before", + createdAt: "2026-01-09T23:59:59.999Z", + uses: 0, + }); + await insertV3Nullifier(pool, { + id: "nil_contract_utc_after", + createdAt: "2026-01-10T00:00:00.000Z", + uses: 8, + }); + await insertV3Nullifier(pool, { + id: "nil_contract_second_action", + actionId: fixture.secondProductionV3ActionId, + createdAt: "2026-01-10T03:00:00.000Z", + uses: 1, + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_utc_before", + createdAt: "2026-01-09T23:59:59.999Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_utc_after", + createdAt: "2026-01-10T00:00:00.000Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_second_action", + actionId: fixture.secondProductionV4ActionId, + createdAt: "2026-01-10T03:00:00.000Z", + }); + await insertV3Nullifier(pool, { + id: "nil_contract_staging", + actionId: fixture.stagingV3ActionId, + createdAt: "2026-01-10T03:00:00.000Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_staging", + actionId: fixture.stagingV4ActionId, + createdAt: "2026-01-10T03:00:00.000Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_production_app_staging", + actionId: fixture.productionStagingV4ActionId, + createdAt: "2026-01-10T03:00:00.000Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_staging_app_production", + actionId: fixture.stagingProductionV4ActionId, + createdAt: "2026-01-10T03:00:00.000Z", + }); + + await expectDatedRollup("2026-01-09", "2026-01-10"); + + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toEqual([ + { + action_id: fixture.productionV3ActionId, + date_utc: "2026-01-09", + unique_count: "1", + }, + { + action_id: fixture.productionV3ActionId, + date_utc: "2026-01-10", + unique_count: "1", + }, + ]); + expect( + await getDailyRows("action_v4_stats_daily", fixture.productionV4ActionId), + ).toEqual([ + { + action_v4_id: fixture.productionV4ActionId, + date_utc: "2026-01-09", + unique_count: "1", + }, + { + action_v4_id: fixture.productionV4ActionId, + date_utc: "2026-01-10", + unique_count: "1", + }, + ]); + + const productionSelected = await endpointBody(); + const productionSelectedPoints = pointMap(productionSelected.app.series); + expect(productionSelectedPoints.get("2026-01-09")).toBe("2"); + expect(productionSelectedPoints.get("2026-01-10")).toBe("4"); + + const stagingSelectedForProductionApp = await endpointBody({ + environment: "staging", + }); + expect( + pointMap(stagingSelectedForProductionApp.app.series).get("2026-01-10"), + ).toBe("1"); + + const stagingSelected = await endpointBody({ + appId: fixture.stagingAppId, + environment: "staging", + }); + expect(pointMap(stagingSelected.app.series).get("2026-01-10")).toBe("2"); + + const productionSelectedForStagingApp = await endpointBody({ + appId: fixture.stagingAppId, + environment: "production", + }); + expect( + pointMap(productionSelectedForStagingApp.app.series).get("2026-01-10"), + ).toBe("1"); + }); + + it("returns a zero-filled app series and requested action blocks without raw-table reads", async () => { + await insertV3Nullifier(pool, { + id: "nil_contract_endpoint", + createdAt: "2026-01-10T12:00:00.000Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_endpoint", + createdAt: "2026-01-12T12:00:00.000Z", + }); + await expectDatedRollup("2026-01-09", "2026-01-13"); + await pool.query("SELECT pg_stat_statements_reset()"); + + const response = await callEndpoint({ + actionIds: [fixture.productionV3ActionId, fixture.productionV4ActionId], + }); + expect(response.status).toBe(200); + const rawBody = await response.json(); + const body = normalizeAnalyticsResponse(rawBody); + + expect(body.period).toBe("all_time"); + expect(body.app.count).toBe("2"); + expect(pointMap(body.app.series).get("2026-01-11")).toBe("0"); + expect(body.legacyActions).toEqual([ + expect.objectContaining({ + id: fixture.productionV3ActionId, + count: "1", + }), + ]); + expect(body.actions).toEqual([ + expect.objectContaining({ + id: fixture.productionV4ActionId, + count: "1", + }), + ]); + expect(JSON.stringify(rawBody)).not.toMatch( + /session|team|reuse|uses|latest|stale|watermark|human/i, + ); + + const statements = await pool.query<{ query: string }>( + `SELECT query + FROM pg_stat_statements + WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND ( + query ILIKE '%stats_daily%' + OR query ILIKE '%world_id%' + OR query ILIKE '%nullifier%' + )`, + ); + const endpointSql = statements.rows.map((row) => row.query).join("\n"); + expect(endpointSql).not.toMatch( + /\bFROM\s+(?:"?public"?\.)?"?nullifier(?:_v4)?"?\b/i, + ); + expect(endpointSql).toMatch(/stats_daily|world_id.*daily/i); + }); + + it("rejects wrong-source and wrong-environment action ids against the real database", async () => { + for (const [actionId, environment] of [ + [fixture.productionV3ActionId, "staging"], + [fixture.stagingV4ActionId, "production"], + ] as const) { + const response = await callEndpoint({ + appId: + actionId === fixture.productionV3ActionId + ? fixture.productionAppId + : fixture.stagingAppId, + actionIds: [actionId], + environment, + }); + expect(response.status).toBe(400); + } + + // productionV4ActionId shares its hex body with productionV3ActionId, so + // strip a v4 id whose body has no v3 counterpart. + const sourceMismatched = fixture.productionStagingV4ActionId.replace( + "action_v4_", + "action_", + ); + expect( + ( + await callEndpoint({ + actionIds: [sourceMismatched], + }) + ).status, + ).toBe(400); + }); + + it("uses app creation for v3-only All Time and RP creation for v4-only All Time", async () => { + await insertV3Nullifier(pool, { + id: "nil_contract_v3_only_start", + actionId: fixture.v3OnlyActionId, + createdAt: "2026-01-05T12:00:00.000Z", + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_v4_only_start", + actionId: fixture.v4OnlyActionId, + createdAt: "2026-01-05T12:00:00.000Z", + }); + await expectDatedRollup("2026-01-05", "2026-01-05"); + + const v3Only = await endpointBody({ appId: fixture.v3OnlyAppId }); + const v4Only = await endpointBody({ appId: fixture.v4OnlyAppId }); + + expect(v3Only.app.series[0].date).toBe("2026-01-01"); + expect(v4Only.app.series[0].date).toBe("2026-01-03"); + }); + + it("matches independent canonical daily and lifetime results for every seeded app, action, source, and environment", async () => { + const v3Actions = [ + fixture.productionV3ActionId, + fixture.secondProductionV3ActionId, + fixture.stagingV3ActionId, + fixture.v3OnlyActionId, + ]; + const v4Actions = [ + fixture.productionV4ActionId, + fixture.secondProductionV4ActionId, + fixture.stagingV4ActionId, + fixture.v4OnlyActionId, + fixture.productionStagingV4ActionId, + fixture.stagingProductionV4ActionId, + ]; + + for (const [index, actionId] of v3Actions.entries()) { + for (const day of [8, 9]) { + await insertV3Nullifier(pool, { + id: `nil_contract_parity_v3_${index}_${day}`, + actionId, + createdAt: `2026-01-${day.toString().padStart(2, "0")}T${(10 + index) + .toString() + .padStart(2, "0")}:00:00.000Z`, + uses: (index + day) % 9, + }); + } + } + for (const [index, actionId] of v4Actions.entries()) { + for (const day of [8, 9]) { + await insertV4Nullifier(pool, { + id: `nullifier_v4_contract_parity_${index}_${day}`, + actionId, + createdAt: `2026-01-${day.toString().padStart(2, "0")}T${(10 + index) + .toString() + .padStart(2, "0")}:30:00.000Z`, + }); + } + } + + const canonical = await readCanonicalSourceDaily(pool, fixture.teamId); + await expectDatedRollup("2026-01-08", "2026-01-09"); + const rolled = await readRolledSourceDaily(pool, fixture.teamId); + + expect(rolled).toEqual(canonical); + expect(toLifetimeRows(rolled)).toEqual(toLifetimeRows(canonical)); + expect(toAppDailyRows(rolled)).toEqual(toAppDailyRows(canonical)); + + const canonicalAppDaily = toAppDailyRows(canonical); + for (const appId of [ + fixture.productionAppId, + fixture.stagingAppId, + fixture.v3OnlyAppId, + fixture.v4OnlyAppId, + ]) { + for (const environment of ["production", "staging"] as const) { + const expected = canonicalAppDaily.filter( + (row) => row.app_id === appId && row.environment === environment, + ); + const endpoint = await endpointBody({ appId, environment }); + const nonZero = endpoint.app.series + .filter((point) => point.count !== "0") + .map((point) => ({ + app_id: appId, + environment, + date_utc: point.date, + count: point.count, + })); + + expect(nonZero).toEqual(expected); + expect(endpoint.app.count).toBe( + expected + .reduce((total, row) => total + BigInt(row.count), 0n) + .toString(), + ); + } + } + }); +}); +// #endregion + +// #region Rebuild, catch-up, cutoff, and mutable v3 fields +describe("World ID analytics [real rebuild and catch-up]", () => { + it("runs a cron-window rebuild, an identical rerun, and an absolute overlap refresh", async () => { + // Anchors stay inside the trailing ~25-hour window every cron-mode call + // rebuilds; the recurring tick never revisits dates older than that. + const anchors = await pool.query<{ cold_at: string; late_at: string }>( + `SELECT + (clock_timestamp() - INTERVAL '3 hours')::text AS cold_at, + (clock_timestamp() - INTERVAL '2 hours')::text AS late_at`, + ); + const { cold_at, late_at } = anchors.rows[0]; + const total = (rows: Array<{ count: string }>) => + rows.reduce((sum, row) => sum + BigInt(row.count), 0n); + + await insertV3Nullifier(pool, { + id: "nil_contract_cold_v3", + createdAt: cold_at, + uses: 3, + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_cold_v4", + createdAt: cold_at, + }); + + await callRollup(); + const first = await readRolledSourceDaily(pool, fixture.teamId); + expect(first).toEqual(await readCanonicalSourceDaily(pool, fixture.teamId)); + + await callRollup(); + expect(await readRolledSourceDaily(pool, fixture.teamId)).toEqual(first); + + // A row committing late — already-processed territory, but inside the + // trailing window — is recaptured by the next absolute rebuild, never + // added incrementally. + await insertV3Nullifier(pool, { + id: "nil_contract_overlap_late", + createdAt: late_at, + uses: 0, + }); + await callRollup(); + const refreshed = await readRolledSourceDaily(pool, fixture.teamId); + expect(refreshed).toEqual( + await readCanonicalSourceDaily(pool, fixture.teamId), + ); + expect(total(refreshed) - total(first)).toBe(1n); + }); + + it("does not move or multiply a v3 point when uses and updated_at change", async () => { + await insertV3Nullifier(pool, { + id: "nil_contract_mutable_fields", + createdAt: "2026-01-10T10:00:00.000Z", + updatedAt: "2026-01-10T10:00:00.000Z", + uses: 0, + }); + await expectDatedRollup("2026-01-10", "2026-01-10"); + await pool.query( + `UPDATE public.nullifier + SET uses = 50, updated_at = '2026-06-01T10:00:00Z' + WHERE id = 'nil_contract_mutable_fields'`, + ); + await expectDatedRollup("2026-01-10", "2026-01-10"); + + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toEqual([ + { + action_id: fixture.productionV3ActionId, + date_utc: "2026-01-10", + unique_count: "1", + }, + ]); + }); + + it("uses one run cutoff and catches a row inserted after the v3 scan on the next run", async () => { + // Totals are summed across dates so a run straddling UTC midnight cannot + // split the two rows onto different date_utc rows and flake. + const anchors = await pool.query<{ initial_at: string; late_at: string }>( + `SELECT + (clock_timestamp() - INTERVAL '2 hours')::text AS initial_at, + (clock_timestamp() - INTERVAL '1 hour')::text AS late_at`, + ); + const { initial_at, late_at } = anchors.rows[0]; + const countedTotal = async () => + ( + await pool.query<{ total: string }>( + `SELECT coalesce(sum(unique_count), 0)::text AS total + FROM public.action_legacy_stats_daily + WHERE action_id = $1`, + [fixture.productionV3ActionId], + ) + ).rows[0].total; + + await insertV3Nullifier(pool, { + id: "nil_contract_before_cutoff", + createdAt: initial_at, + }); + await installPauseTrigger(); + const blocker = await pool.connect(); + try { + await blocker.query("SELECT pg_advisory_lock($1, $2)", barrierLock); + const firstRun = callRollup(); + await waitForAdvisoryWaiter(barrierLock); + + await insertV3Nullifier(pool, { + id: "nil_contract_after_v3_scan", + createdAt: late_at, + }); + await releaseBarrier(blocker); + await firstRun; + + // The mid-run insert is invisible to the first run's single snapshot. + expect(await countedTotal()).toBe("1"); + + await callRollup(); + expect(await countedTotal()).toBe("2"); + } finally { + await blocker.query("SELECT pg_advisory_unlock_all()"); + blocker.release(); + } + }); + + it("honors the safety delay around the database-owned cutoff", async () => { + // Timing budget: the second row must still be younger than the 5-minute + // safety delay when the rollup takes its cutoff, so the rollup has to + // start within 4 minutes of this insert. + await pool.query( + `INSERT INTO public.nullifier_v4 ( + id, action_v4_id, created_at, nullifier + ) VALUES + ('nullifier_v4_contract_safely_before', $1, + clock_timestamp() - INTERVAL '10 minutes', 900001), + ('nullifier_v4_contract_inside_delay', $1, + clock_timestamp() - INTERVAL '1 minute', 900002)`, + [fixture.productionV4ActionId], + ); + + await callRollup(); + + expect( + await getDailyRows("action_v4_stats_daily", fixture.productionV4ActionId), + ).toEqual([ + expect.objectContaining({ + action_v4_id: fixture.productionV4ActionId, + unique_count: "1", + }), + ]); + }); + + it("repairs an arbitrary multi-day historical gap through one dated call", async () => { + for (const day of [20, 21, 22, 23, 24, 25]) { + await insertV3Nullifier(pool, { + id: `nil_contract_gap_${day}`, + createdAt: `2026-01-${day}T12:00:00.000Z`, + uses: day % 9, + }); + } + + await expectDatedRollup("2026-01-20", "2026-01-25"); + + expect( + ( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ) + ).map((row) => row.date_utc), + ).toEqual([ + "2026-01-20", + "2026-01-21", + "2026-01-22", + "2026-01-23", + "2026-01-24", + "2026-01-25", + ]); + }); +}); +// #endregion + +// #region Atomicity and single-run exclusion +describe("World ID analytics [real atomicity and locking]", () => { + it("rolls back both source legs when the v4 leg fails", async () => { + const anchor = await pool.query<{ recent_at: string }>( + `SELECT (clock_timestamp() - INTERVAL '2 hours')::text AS recent_at`, + ); + await insertV3Nullifier(pool, { + id: "nil_contract_atomic_v3", + createdAt: anchor.rows[0].recent_at, + }); + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_atomic_v4", + createdAt: anchor.rows[0].recent_at, + }); + await pool.query( + `CREATE FUNCTION public.contract_fail_v4_analytics_rollup() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'contract v4 leg failure'; + END + $$`, + ); + await pool.query( + `CREATE TRIGGER contract_fail_v4_analytics_rollup + BEFORE INSERT ON public.action_v4_stats_daily + FOR EACH ROW EXECUTE FUNCTION public.contract_fail_v4_analytics_rollup()`, + ); + + await expect(callRollup()).rejects.toThrow("rollup failed (500)"); + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toEqual([]); + expect( + await getDailyRows("action_v4_stats_daily", fixture.productionV4ActionId), + ).toEqual([]); + }); + + it("queues a concurrent cron invocation behind the running one and loses nothing", async () => { + const anchor = await pool.query<{ recent_at: string }>( + `SELECT (clock_timestamp() - INTERVAL '2 hours')::text AS recent_at`, + ); + await insertV3Nullifier(pool, { + id: "nil_contract_lock", + createdAt: anchor.rows[0].recent_at, + }); + await installPauseTrigger(); + const blocker = await pool.connect(); + try { + await blocker.query("SELECT pg_advisory_lock($1, $2)", barrierLock); + const firstRun = callRollup(); + await waitForAdvisoryWaiter(barrierLock); + + // The second invocation blocks on the rollup's own advisory lock while + // the first holds it at the barrier — no skip, no interleaving. + const secondRun = callRollup(); + await waitForAdvisoryWaiter(rollupLock); + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toEqual([]); + + await releaseBarrier(blocker); + await expect(firstRun).resolves.toEqual( + expect.objectContaining({ success: true, outcome: "advanced" }), + ); + await expect(secondRun).resolves.toEqual( + expect.objectContaining({ success: true, outcome: "advanced" }), + ); + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toEqual([expect.objectContaining({ unique_count: "1" })]); + } finally { + await blocker.query("SELECT pg_advisory_unlock_all()"); + blocker.release(); + } + }); + + it("remains independent of the legacy app-stats lock (533214,42)", async () => { + const anchor = await pool.query<{ recent_at: string }>( + `SELECT (clock_timestamp() - INTERVAL '2 hours')::text AS recent_at`, + ); + await insertV3Nullifier(pool, { + id: "nil_contract_exact_lock", + createdAt: anchor.rows[0].recent_at, + }); + const lockClient = await pool.connect(); + try { + await lockClient.query("BEGIN"); + await lockClient.query("SELECT pg_advisory_xact_lock(533214, 42)"); + await expect(callRollup()).resolves.toEqual( + expect.objectContaining({ success: true, outcome: "advanced" }), + ); + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toHaveLength(1); + } finally { + await lockClient.query("ROLLBACK"); + lockClient.release(); + } + }); +}); +// #endregion + +// #region Deletion and no resurrection +describe("World ID analytics [real action deletion]", () => { + it.each([ + [ + "v3", + fixture.productionV3ActionId, + "action_legacy_stats_daily", + "action_id", + ], + [ + "v4", + fixture.productionV4ActionId, + "action_v4_stats_daily", + "action_v4_id", + ], + ] as const)( + "cascades %s history and does not resurrect it", + async (source, actionId, table, idColumn) => { + if (source === "v3") { + await insertV3Nullifier(pool, { + id: "nil_contract_delete_v3", + createdAt: "2026-01-10T12:00:00.000Z", + }); + } else { + await insertV4Nullifier(pool, { + id: "nullifier_v4_contract_delete_v4", + createdAt: "2026-01-10T12:00:00.000Z", + }); + } + await expectDatedRollup("2026-01-10", "2026-01-10"); + + await pool.query( + `DELETE FROM public.${source === "v3" ? "action" : "action_v4"} + WHERE id = $1`, + [actionId], + ); + await expectDatedRollup("2026-01-10", "2026-01-10"); + + expect( + ( + await pool.query( + `SELECT 1 FROM public.${table} WHERE ${idColumn} = $1`, + [actionId], + ) + ).rows, + ).toEqual([]); + }, + ); + + it("parks a concurrent action deletion behind the running chunk, then cascades", async () => { + // The rollup pre-locks its parent actions before touching child rows, so + // a mid-chunk deletion queues on the parent instead of deadlocking (the + // old cycle's 40P01 victim was the user's delete) and cascades cleanly + // once the chunk commits. + await insertV3Nullifier(pool, { + id: "nil_contract_delete_race", + createdAt: "2026-01-10T12:00:00.000Z", + }); + await expectDatedRollup("2026-01-10", "2026-01-10"); + await installPauseTrigger(); + const blocker = await pool.connect(); + const deleteClient = await pool.connect(); + try { + await blocker.query("SELECT pg_advisory_lock($1, $2)", barrierLock); + const rollup = callRollup({ + from_date: "2026-01-10", + to_date: "2026-01-10", + }); + await waitForAdvisoryWaiter(barrierLock); + + await deleteClient.query("SET statement_timeout = '10s'"); + const deletion = deleteClient + .query("DELETE FROM public.action WHERE id = $1", [ + fixture.productionV3ActionId, + ]) + .then( + (result) => ({ ok: true as const, rows: result.rowCount }), + (error: { code?: string }) => ({ + ok: false as const, + code: error.code, + }), + ); + for (let attempt = 0; attempt < 1_000; attempt += 1) { + const waiting = await pool.query<{ waiting: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE wait_event_type = 'Lock' + AND query LIKE 'DELETE FROM public.action%' + ) AS waiting`, + ); + if (waiting.rows[0].waiting) break; + } + + await releaseBarrier(blocker); + await expect(rollup).resolves.toEqual({ + success: true, + chunks: 1, + failed_ranges: [], + }); + await expect(deletion).resolves.toEqual({ ok: true, rows: 1 }); + + // The cascade swept the chunk's freshly written rows with the action, + // and a re-roll of the range stays empty. + await expectDatedRollup("2026-01-10", "2026-01-10"); + expect( + await getDailyRows( + "action_legacy_stats_daily", + fixture.productionV3ActionId, + ), + ).toEqual([]); + } finally { + await blocker.query("SELECT pg_advisory_unlock_all()"); + blocker.release(); + deleteClient.release(); + } + }); +}); +// #endregion + +// #region Applied metadata and role isolation +describe("World ID analytics [real Hasura metadata]", () => { + it("applies the fifteen-minute protected cron while preserving the legacy hourly job", async () => { + const response = await fetch( + process.env.WIA_HASURA_METADATA_URL as string, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-hasura-admin-secret": process.env + .HASURA_GRAPHQL_ADMIN_SECRET as string, + }, + body: JSON.stringify({ type: "export_metadata", args: {} }), + }, + ); + expect(response.status).toBe(200); + const metadata = (await response.json()) as { + cron_triggers: Array<{ + headers?: Array<{ name: string; value_from_env?: string }>; + name: string; + schedule: string; + webhook: string; + }>; + }; + const analyticsCron = metadata.cron_triggers.find((trigger) => + /world id analytics/i.test(trigger.name), + ); + expect(analyticsCron).toEqual( + expect.objectContaining({ + schedule: "*/15 * * * *", + webhook: "{{NEXT_API_URL}}/_rollup-world-id-analytics", + }), + ); + expect(analyticsCron?.headers).toContainEqual({ + name: "Authorization", + value_from_env: "INTERNAL_ENDPOINTS_SECRET", + }); + expect( + metadata.cron_triggers.find( + (trigger) => trigger.name === "Rollup app stats", + ), + ).toEqual(expect.objectContaining({ schedule: "0 * * * *" })); + }); + + it("exposes every tracked analytics table, return object, and function only to service", async () => { + const metadataResponse = await fetch( + process.env.WIA_HASURA_METADATA_URL as string, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-hasura-admin-secret": process.env + .HASURA_GRAPHQL_ADMIN_SECRET as string, + }, + body: JSON.stringify({ type: "export_metadata", args: {} }), + }, + ); + const metadata = (await metadataResponse.json()) as { + sources: Array<{ + functions?: Array<{ + configuration?: { + custom_root_fields?: { function?: string }; + }; + function: { name: string; schema: string }; + }>; + name: string; + tables: Array<{ + configuration?: { + custom_root_fields?: { select?: string }; + }; + table: { name: string; schema: string }; + }>; + }>; + }; + const source = metadata.sources.find((item) => item.name === "default"); + expect(source).toBeDefined(); + + const analyticsName = + /(?:world_id.*(?:analytics|stats_daily)|(?:analytics|stats_daily).*world_id|action_(?:legacy|v4)_stats_daily)/i; + const trackedTables = (source?.tables ?? []).filter((item) => + analyticsName.test(item.table.name), + ); + const trackedFunctions = (source?.functions ?? []).filter((item) => + analyticsName.test(item.function.name), + ); + const returnedRelations = await pool.query<{ relation_name: string }>( + `SELECT DISTINCT return_relation.relname AS relation_name + FROM pg_proc + JOIN pg_namespace + ON pg_namespace.oid = pg_proc.pronamespace + JOIN pg_class AS return_relation + ON return_relation.oid = pg_proc.prorettype + WHERE pg_namespace.nspname = 'public' + AND pg_proc.proname ~* + '(world_id.*(analytics|stats_daily)|(analytics|stats_daily).*world_id)'`, + ); + const returnedNames = new Set( + returnedRelations.rows.map((row) => row.relation_name), + ); + for (const item of source?.tables ?? []) { + if (returnedNames.has(item.table.name) && !trackedTables.includes(item)) { + trackedTables.push(item); + } + } + + const tableRoots = trackedTables.map( + (item) => + item.configuration?.custom_root_fields?.select ?? item.table.name, + ); + const functionRoots = trackedFunctions.map( + (item) => + item.configuration?.custom_root_fields?.function ?? item.function.name, + ); + const everyRoot = [...tableRoots, ...functionRoots]; + + expect(trackedTables.map((item) => item.table.name)).toEqual( + expect.arrayContaining([ + "action_legacy_stats_daily", + "action_v4_stats_daily", + "world_id_app_stats_daily", + ]), + ); + expect(trackedFunctions.length).toBeGreaterThanOrEqual(2); + expect( + trackedFunctions.some((item) => /rollup/i.test(item.function.name)), + ).toBe(true); + expect( + trackedFunctions.some((item) => /app.*daily/i.test(item.function.name)), + ).toBe(true); + + const serviceToken = await generateServiceJWT(); + const userToken = ( + await generateUserJWT("usr_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ).token; + const service = await readRoleSchema({ + authorization: `Bearer ${serviceToken}`, + }); + const user = await readRoleSchema({ + authorization: `Bearer ${userToken}`, + }); + const publicRole = await readRoleSchema(); + + for (const root of everyRoot) { + expect(service.roots).toContain(root); + expect(user.roots).not.toContain(root); + expect(publicRole.roots).not.toContain(root); + } + for (const table of trackedTables) { + expect(service.types).toContain(table.table.name); + expect(user.types).not.toContain(table.table.name); + expect(publicRole.types).not.toContain(table.table.name); + } + + const serviceTableQuery = trackedTables + .map( + (table, index) => + `table_${index}: ${ + table.configuration?.custom_root_fields?.select ?? table.table.name + }(limit: 0) { __typename }`, + ) + .join("\n"); + const serviceRead = await graphRequest({ + headers: { authorization: `Bearer ${serviceToken}` }, + query: `query EveryAnalyticsTable { ${serviceTableQuery} }`, + }); + expect(serviceRead.body.errors).toBeUndefined(); + expect(Object.keys(serviceRead.body.data ?? {})).toHaveLength( + trackedTables.length, + ); + }); +}); +// #endregion diff --git a/web/tests/world-id-analytics/million.test.ts b/web/tests/world-id-analytics/million.test.ts new file mode 100644 index 000000000..f6ab1aae1 --- /dev/null +++ b/web/tests/world-id-analytics/million.test.ts @@ -0,0 +1,702 @@ +import { POST as rollupWorldIdAnalytics } from "@/api/_rollup-world-id-analytics"; +import { GET as getWorldIdAnalytics } from "@/api/portal/apps/[app_id]/world-id-analytics"; +import { NextRequest } from "next/server"; +import { execFileSync } from "node:child_process"; +import { performance } from "node:perf_hooks"; +import { Pool } from "pg"; +import { normalizeAnalyticsResponse } from "../contracts/world-id-analytics-endpoint"; +import { + readCanonicalSourceDaily, + readRolledSourceDaily, + toAppDailyRows, + toAppSourceLifetimeRows, + toLifetimeRows, + type AppDailyRow, + type SourceDailyRow, +} from "./canonical-analytics"; +import { fixture, resetFixture, seedFixture } from "./fresh-stack-fixture"; + +// #region Mocks +const getIsUserAllowedToReadApp = jest.fn().mockResolvedValue(true); + +jest.mock("@/lib/logger", () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock("@/lib/permissions", () => ({ + getIsUserAllowedToReadApp: (...args: unknown[]) => + getIsUserAllowedToReadApp(...args), +})); +// #endregion + +// #region Test Data +const pool = new Pool({ max: 6 }); +const enabled = process.env.WIA_ANALYTICS_MILLION === "1"; +const sampleCount = 10; + +type EndpointSample = { + body: ReturnType; + bytes: number; + elapsedMs: number; +}; + +const runRollup = async () => { + const started = performance.now(); + const response = await rollupWorldIdAnalytics( + new NextRequest("http://localhost:3000/api/_rollup-world-id-analytics", { + method: "POST", + headers: { + authorization: process.env.INTERNAL_ENDPOINTS_SECRET as string, + }, + }), + ); + const elapsedMs = performance.now() - started; + const body = await response.json(); + expect({ status: response.status, body }).toEqual({ + status: 200, + body: { + success: true, + outcome: "advanced", + days: expect.any(Number), + total: expect.any(String), + }, + }); + return elapsedMs; +}; + +const utcDate = (daysAgo: number) => { + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - daysAgo, + ), + ) + .toISOString() + .slice(0, 10); +}; + +// The initial full-history backfill follows the production runbook: dated +// POSTs to the same route the cron uses, chunked into bounded per-chunk +// transactions (the 31-day seed spans several chunks). +const runDatedBackfill = async () => { + const started = performance.now(); + const response = await rollupWorldIdAnalytics( + new NextRequest("http://localhost:3000/api/_rollup-world-id-analytics", { + method: "POST", + headers: { + authorization: process.env.INTERNAL_ENDPOINTS_SECRET as string, + }, + body: JSON.stringify({ + from_date: utcDate(31), + to_date: utcDate(0), + chunk_days: 10, + }), + }), + ); + const elapsedMs = performance.now() - started; + const body = await response.json(); + expect({ status: response.status, body }).toEqual({ + status: 200, + body: { success: true, chunks: 4, failed_ranges: [] }, + }); + return elapsedMs; +}; + +const readEndpoint = async ( + period: "all_time" | "last_7_days", +): Promise => { + const started = performance.now(); + const response = await getWorldIdAnalytics( + new NextRequest( + `http://localhost:3000/api/portal/apps/${fixture.productionAppId}/world-id-analytics?environment=production&period=${period}`, + ), + { params: Promise.resolve({ app_id: fixture.productionAppId }) }, + ); + const text = await response.text(); + const elapsedMs = performance.now() - started; + expect(response.status).toBe(200); + return { + body: normalizeAnalyticsResponse(JSON.parse(text)), + bytes: Buffer.byteLength(text), + elapsedMs, + }; +}; + +const percentile = (values: number[], percentileValue: number) => { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.max( + 0, + Math.ceil((percentileValue / 100) * sorted.length) - 1, + ); + return sorted[index]; +}; + +const summarizeSamples = (samples: EndpointSample[]) => ({ + p50Ms: percentile( + samples.map((sample) => sample.elapsedMs), + 50, + ), + p95Ms: percentile( + samples.map((sample) => sample.elapsedMs), + 95, + ), + payloadP50Bytes: percentile( + samples.map((sample) => sample.bytes), + 50, + ), + payloadMaxBytes: Math.max(...samples.map((sample) => sample.bytes)), +}); + +const applyOptionalBudget = (environmentVariable: string, actual: number) => { + const configured = process.env[environmentVariable]; + if (configured === undefined) return false; + + const budget = Number(configured); + expect(Number.isFinite(budget) && budget > 0).toBe(true); + expect(actual).toBeLessThanOrEqual(budget); + return true; +}; + +// auto_explain at log_min_duration=0 logs every statement, so the full +// history since boot outgrows Node's maximum string length under load. +// Capture windows with --since (1s of skew slack) instead of prefix-diffing +// full reads. +const readOwnPostgresLogs = (since: string) => + execFileSync( + "docker", + [ + "compose", + "--project-name", + process.env.WIA_COMPOSE_PROJECT as string, + "--file", + process.env.WIA_COMPOSE_FILE as string, + "logs", + "--no-color", + "--since", + since, + "postgres", + ], + { encoding: "utf8", maxBuffer: 1024 * 1024 * 1024 }, + ); + +const logWindowStart = () => new Date(Date.now() - 1_000).toISOString(); + +const relevantPlanLines = (plan: string) => + plan + .split(/\r?\n/) + .filter((line) => + /duration:|Buffers:|Scan|Index|Filter:|Function|stats_daily|nullifier/i.test( + line, + ), + ) + .join("\n"); + +const expectActualRecurringPlans = (plan: string) => { + expect(plan).toMatch(/duration:.*plan:/i); + expect(plan).toMatch(/Buffers:/i); + for (const table of ["nullifier", "nullifier_v4"]) { + expect(plan).toMatch( + new RegExp( + `(?:(?:Index|Bitmap)[^\\n]*${table}|${table}[^\\n]*(?:Index|Bitmap))`, + "i", + ), + ); + } +}; + +const expectActualAppReadPlans = (plan: string) => { + expect(plan).toMatch(/duration:.*plan:/i); + expect(plan).toMatch(/Buffers:/i); + expect(plan).toMatch(/action_legacy_stats_daily|action_v4_stats_daily/i); + expect(plan).not.toMatch( + /(?:Seq Scan|Index Scan|Index Only Scan|Bitmap Heap Scan)\s+on\s+(?:public\.)?nullifier(?:_v4)?\b/i, + ); +}; + +const assertCanonicalParity = async () => { + const canonical = await readCanonicalSourceDaily(pool, fixture.teamId); + const rolled = await readRolledSourceDaily(pool, fixture.teamId); + + expect(rolled.filter((row) => row.source === "legacy")).toEqual( + canonical.filter((row) => row.source === "legacy"), + ); + expect(rolled.filter((row) => row.source === "v4")).toEqual( + canonical.filter((row) => row.source === "v4"), + ); + expect(toLifetimeRows(rolled)).toEqual(toLifetimeRows(canonical)); + expect(toAppSourceLifetimeRows(rolled)).toEqual( + toAppSourceLifetimeRows(canonical), + ); + expect(toAppDailyRows(rolled)).toEqual(toAppDailyRows(canonical)); + return canonical; +}; + +const assertEveryAppEnvironmentEndpoint = async ( + canonical: SourceDailyRow[], +) => { + const expectedDaily = toAppDailyRows(canonical); + const expectedSourceTotals = toAppSourceLifetimeRows(canonical); + for (const appId of [ + fixture.productionAppId, + fixture.stagingAppId, + fixture.v3OnlyAppId, + fixture.v4OnlyAppId, + ]) { + for (const environment of ["production", "staging"] as const) { + const expectedActionDaily = canonical.filter( + (row) => row.app_id === appId && row.environment === environment, + ); + const actionIds = [ + ...new Set(expectedActionDaily.map((row) => row.action_id)), + ]; + const search = new URLSearchParams({ + environment, + period: "all_time", + }); + if (actionIds.length > 0) search.set("action_ids", actionIds.join(",")); + const expected = expectedDaily.filter( + (row) => row.app_id === appId && row.environment === environment, + ); + const response = await getWorldIdAnalytics( + new NextRequest( + `http://localhost:3000/api/portal/apps/${appId}/world-id-analytics?${search}`, + ), + { params: Promise.resolve({ app_id: appId }) }, + ); + expect(response.status).toBe(200); + const body = normalizeAnalyticsResponse(await response.json()); + const actual = body.app.series + .filter((point) => point.count !== "0") + .map((point) => ({ + app_id: appId, + environment, + date_utc: point.date, + count: point.count, + })); + expect(actual).toEqual(expected); + expect(body.app.count).toBe( + expected + .reduce((total, row) => total + BigInt(row.count), 0n) + .toString(), + ); + + const actualActionMetrics = [ + ...body.legacyActions.map((metric) => ({ + ...metric, + source: "legacy" as const, + })), + ...body.actions.map((metric) => ({ + ...metric, + source: "v4" as const, + })), + ]; + const actualActionDaily = actualActionMetrics + .flatMap((metric) => + metric.series + .filter((point) => point.count !== "0") + .map((point) => ({ + action_id: metric.id, + app_id: appId, + count: point.count, + date_utc: point.date, + environment, + source: metric.source, + })), + ) + .sort( + (left, right) => + [ + left.source.localeCompare(right.source), + left.action_id.localeCompare(right.action_id), + left.date_utc.localeCompare(right.date_utc), + ].find((comparison) => comparison !== 0) ?? 0, + ); + expect(actualActionDaily).toEqual(expectedActionDaily); + + const actualSourceTotals = [ + ...new Set(actualActionMetrics.map((metric) => metric.source)), + ] + .map((source) => ({ + app_id: appId, + count: actualActionMetrics + .filter((metric) => metric.source === source) + .reduce((total, metric) => total + BigInt(metric.count), 0n) + .toString(), + environment, + source, + })) + .sort((left, right) => left.source.localeCompare(right.source)); + expect(actualSourceTotals).toEqual( + expectedSourceTotals.filter( + (row) => row.app_id === appId && row.environment === environment, + ), + ); + } + } +}; + +const expectedForReturnedDates = ( + expectedDaily: AppDailyRow[], + sample: EndpointSample, +) => { + const returnedDates = new Set( + sample.body.app.series.map((point) => point.date), + ); + return expectedDaily + .filter( + (row) => + row.app_id === fixture.productionAppId && + row.environment === "production" && + returnedDates.has(row.date_utc), + ) + .reduce((total, row) => total + BigInt(row.count), 0n) + .toString(); +}; +// #endregion + +beforeAll(async () => { + if (enabled && process.env.WIA_FRESH_STACK !== "true") { + throw new Error( + "Run through pnpm test:world-id-analytics:million; shared stacks are forbidden", + ); + } +}); + +afterAll(async () => { + if (enabled) await resetFixture(pool); + await pool.end(); +}); + +(enabled ? describe : describe.skip)( + "World ID analytics [opt-in one-million-row gate]", + () => { + jest.setTimeout(600_000); + + it("proves canonical parity, overlap recovery, actual plans, and endpoint evidence", async () => { + await resetFixture(pool); + await seedFixture(pool); + + const databaseAnchor = await pool.query<{ + captured_at: string; + initial_cutoff_anchor: string; + newest_initial_at: string; + }>( + `WITH anchor AS ( + SELECT clock_timestamp() AS captured_at + ) + SELECT + captured_at::text, + (captured_at - INTERVAL '5 minutes')::text + AS initial_cutoff_anchor, + (captured_at - INTERVAL '2 hours')::text AS newest_initial_at + FROM anchor`, + ); + expect(databaseAnchor.rows).toHaveLength(1); + const { captured_at, initial_cutoff_anchor, newest_initial_at } = + databaseAnchor.rows[0]; + expect(new Date(newest_initial_at).getTime()).toBeLessThan( + new Date(initial_cutoff_anchor).getTime(), + ); + expect(new Date(initial_cutoff_anchor).getTime()).toBeLessThan( + new Date(captured_at).getTime(), + ); + + await pool.query( + `INSERT INTO public.nullifier ( + id, action_id, created_at, updated_at, nullifier_hash, uses + ) + SELECT + 'nil_load_' || lpad(value::text, 12, '0'), + (ARRAY[$1, $2, $3, $4]::text[])[((value - 1) % 4) + 1], + $5::timestamptz + - ((value - 1) % 31) * INTERVAL '24 hours', + $5::timestamptz + - ((value - 1) % 31) * INTERVAL '24 hours', + 'load_hash_' || value, + value % 9 + FROM generate_series(1, 500000) AS value`, + [ + fixture.productionV3ActionId, + fixture.secondProductionV3ActionId, + fixture.stagingV3ActionId, + fixture.v3OnlyActionId, + newest_initial_at, + ], + ); + await pool.query( + `INSERT INTO public.nullifier_v4 ( + id, action_v4_id, created_at, nullifier + ) + SELECT + 'nullifier_v4_load_' || lpad(value::text, 12, '0'), + (ARRAY[$1, $2, $3, $4, $5, $6]::text[])[((value - 1) % 6) + 1], + $7::timestamptz + - ((value - 1) % 31) * INTERVAL '24 hours', + (2000000 + value)::numeric + FROM generate_series(1, 500000) AS value`, + [ + fixture.productionV4ActionId, + fixture.secondProductionV4ActionId, + fixture.stagingV4ActionId, + fixture.v4OnlyActionId, + fixture.productionStagingV4ActionId, + fixture.stagingProductionV4ActionId, + newest_initial_at, + ], + ); + + const initialRawCount = await pool.query<{ count: string }>( + `SELECT ( + (SELECT count(*) FROM public.nullifier + WHERE id LIKE 'nil_load_%') + + + (SELECT count(*) FROM public.nullifier_v4 + WHERE id LIKE 'nullifier_v4_load_%') + )::text AS count`, + ); + expect(initialRawCount.rows[0].count).toBe("1000000"); + + const initialCanonical = await readCanonicalSourceDaily( + pool, + fixture.teamId, + ); + expect( + initialCanonical.reduce((total, row) => total + BigInt(row.count), 0n), + ).toBe(1_000_000n); + expect(new Set(initialCanonical.map((row) => row.source))).toEqual( + new Set(["legacy", "v4"]), + ); + expect(new Set(initialCanonical.map((row) => row.environment))).toEqual( + new Set(["production", "staging"]), + ); + expect(new Set(initialCanonical.map((row) => row.app_id)).size).toBe(4); + expect(new Set(initialCanonical.map((row) => row.action_id)).size).toBe( + 10, + ); + expect(new Set(initialCanonical.map((row) => row.date_utc)).size).toBe( + 31, + ); + + const backfillMs = await runDatedBackfill(); + const afterBackfill = await assertCanonicalParity(); + expect(afterBackfill).toEqual(initialCanonical); + await assertEveryAppEnvironmentEndpoint(afterBackfill); + + const indexEvidence = await pool.query<{ + has_created_at_index: boolean; + table_name: string; + }>( + `SELECT + expected.table_name, + bool_or( + pg_index.indisvalid + AND pg_index.indisready + AND (pg_get_indexdef(pg_index.indexrelid) ~* '\\(created_at') + ) AS has_created_at_index + FROM unnest(ARRAY['nullifier', 'nullifier_v4']) + AS expected(table_name) + JOIN pg_class ON pg_class.relname = expected.table_name + JOIN pg_namespace + ON pg_namespace.oid = pg_class.relnamespace + AND pg_namespace.nspname = 'public' + LEFT JOIN pg_index ON pg_index.indrelid = pg_class.oid + GROUP BY expected.table_name + ORDER BY expected.table_name`, + ); + expect(indexEvidence.rows).toEqual([ + { table_name: "nullifier", has_created_at_index: true }, + { table_name: "nullifier_v4", has_created_at_index: true }, + ]); + + // Late-arriving rows land at a timestamp the backfill already + // processed; it sits well inside the trailing ~25h window every + // cron-mode call rebuilds, so the next tick must recapture it. + const catchup_at = newest_initial_at; + const catchupDateRow = await pool.query<{ catchup_date: string }>( + `SELECT ($1::timestamptz AT TIME ZONE 'UTC')::date::text + AS catchup_date`, + [catchup_at], + ); + const catchup_date = catchupDateRow.rows[0].catchup_date; + const before = await pool.query<{ + v3_before: string; + v4_before: string; + }>( + `SELECT + ( + SELECT coalesce(sum(unique_count), 0)::text + FROM public.action_legacy_stats_daily + WHERE date_utc = $1::date + ) AS v3_before, + ( + SELECT coalesce(sum(unique_count), 0)::text + FROM public.action_v4_stats_daily + WHERE date_utc = $1::date + ) AS v4_before`, + [catchup_date], + ); + const { v3_before, v4_before } = before.rows[0]; + expect(BigInt(v3_before)).toBeGreaterThan(0n); + expect(BigInt(v4_before)).toBeGreaterThan(0n); + + await pool.query( + `INSERT INTO public.nullifier ( + id, action_id, created_at, updated_at, nullifier_hash, uses + ) + SELECT + 'nil_catchup_' || lpad(value::text, 12, '0'), + (ARRAY[$1, $2, $3, $4]::text[])[((value - 1) % 4) + 1], + $5::timestamptz, + $5::timestamptz, + 'catchup_hash_' || value, + value % 9 + FROM generate_series(1, 5000) AS value`, + [ + fixture.productionV3ActionId, + fixture.secondProductionV3ActionId, + fixture.stagingV3ActionId, + fixture.v3OnlyActionId, + catchup_at, + ], + ); + await pool.query( + `INSERT INTO public.nullifier_v4 ( + id, action_v4_id, created_at, nullifier + ) + SELECT + 'nullifier_v4_catchup_' || lpad(value::text, 12, '0'), + (ARRAY[$1, $2, $3, $4, $5, $6]::text[])[((value - 1) % 6) + 1], + $7::timestamptz, + (3000000 + value)::numeric + FROM generate_series(1, 5000) AS value`, + [ + fixture.productionV4ActionId, + fixture.secondProductionV4ActionId, + fixture.stagingV4ActionId, + fixture.v4OnlyActionId, + fixture.productionStagingV4ActionId, + fixture.stagingProductionV4ActionId, + catchup_at, + ], + ); + + const catchupLogWindow = logWindowStart(); + const catchupMs = await runRollup(); + const catchupPlans = readOwnPostgresLogs(catchupLogWindow); + expectActualRecurringPlans(catchupPlans); + + const recaptured = await pool.query<{ + v3_after: string; + v4_after: string; + }>( + `SELECT + ( + SELECT coalesce(sum(unique_count), 0)::text + FROM public.action_legacy_stats_daily + WHERE date_utc = $1::date + ) AS v3_after, + ( + SELECT coalesce(sum(unique_count), 0)::text + FROM public.action_v4_stats_daily + WHERE date_utc = $1::date + ) AS v4_after`, + [catchup_date], + ); + expect(BigInt(recaptured.rows[0].v3_after) - BigInt(v3_before)).toBe( + 5_000n, + ); + expect(BigInt(recaptured.rows[0].v4_after) - BigInt(v4_before)).toBe( + 5_000n, + ); + + const afterCatchup = await assertCanonicalParity(); + expect( + afterCatchup.reduce((total, row) => total + BigInt(row.count), 0n), + ).toBe(1_010_000n); + await assertEveryAppEnvironmentEndpoint(afterCatchup); + + const expectedDaily = toAppDailyRows(afterCatchup); + await readEndpoint("last_7_days"); + await readEndpoint("all_time"); + // Separate the window from the preceding canonical raw-SQL scans so + // the skew slack cannot pull them into the no-raw-reads assertion. + await new Promise((resolve) => setTimeout(resolve, 1_500)); + const endpointWindow = logWindowStart(); + const last7Samples: EndpointSample[] = []; + const allTimeSamples: EndpointSample[] = []; + for (let sample = 0; sample < sampleCount; sample += 1) { + last7Samples.push(await readEndpoint("last_7_days")); + allTimeSamples.push(await readEndpoint("all_time")); + } + const endpointPlans = readOwnPostgresLogs(endpointWindow); + expectActualAppReadPlans(endpointPlans); + + for (const sample of [...last7Samples, ...allTimeSamples]) { + expect(sample.body.app.count).toBe( + expectedForReturnedDates(expectedDaily, sample), + ); + } + + const last7Evidence = summarizeSamples(last7Samples); + const allTimeEvidence = summarizeSamples(allTimeSamples); + const configuredBudgets = { + last7P95: applyOptionalBudget( + "WIA_ANALYTICS_LAST_7_P95_BUDGET_MS", + last7Evidence.p95Ms, + ), + allTimeP95: applyOptionalBudget( + "WIA_ANALYTICS_ALL_TIME_P95_BUDGET_MS", + allTimeEvidence.p95Ms, + ), + payload: applyOptionalBudget( + "WIA_ANALYTICS_PAYLOAD_BUDGET_BYTES", + Math.max( + last7Evidence.payloadMaxBytes, + allTimeEvidence.payloadMaxBytes, + ), + ), + backfill: applyOptionalBudget( + "WIA_ANALYTICS_BACKFILL_BUDGET_MS", + backfillMs, + ), + catchup: applyOptionalBudget( + "WIA_ANALYTICS_CATCHUP_BUDGET_MS", + catchupMs, + ), + }; + const outstandingProductionGates = Object.entries(configuredBudgets) + .filter(([, configured]) => !configured) + .map(([name]) => name); + + console.info( + JSON.stringify( + { + rows: { + initial: 1_000_000, + afterCatchup: 1_010_000, + }, + rollupMs: { + backfill: Math.round(backfillMs), + catchup: Math.round(catchupMs), + }, + endpoint: { + samplesPerPeriod: sampleCount, + last7Days: last7Evidence, + allTime: allTimeEvidence, + }, + configuredBudgets, + outstandingProductionGates, + actualPlans: { + recurringCatchup: relevantPlanLines(catchupPlans), + endpointReads: relevantPlanLines(endpointPlans), + }, + }, + null, + 2, + ), + ); + }); + }, +); diff --git a/web/tests/world-id-analytics/run-fresh-stack.sh b/web/tests/world-id-analytics/run-fresh-stack.sh index eb289766e..c000ba780 100755 --- a/web/tests/world-id-analytics/run-fresh-stack.sh +++ b/web/tests/world-id-analytics/run-fresh-stack.sh @@ -153,6 +153,20 @@ if [[ "${1:-}" == "--release-gate" ]]; then exit 0 fi +if [[ "${1:-}" == "--million" ]]; then + export WIA_ANALYTICS_MILLION=1 + # The v3 created_at index is deliberately an out-of-band operator step + # (never a transactional migration); perform it here exactly as the + # runbook does in production, before the backfill the test drives. + docker compose --project-name "${compose_project}" --file "${compose_file}" \ + exec --no-TTY postgres psql --username postgres --dbname postgres \ + --file - \ + < "${repository_root}/hasura/operations/world-id-analytics/create-nullifier-created-at-index.sql" + npx jest tests/world-id-analytics/stack-smoke.test.ts --runInBand + npx jest tests/world-id-analytics/million.test.ts --runInBand + exit 0 +fi + npx jest \ tests/world-id-analytics/stack-smoke.test.ts \ tests/world-id-analytics/window-rollup.test.ts \ @@ -161,3 +175,4 @@ npx jest \ tests/world-id-analytics/cron-and-rollout.test.ts \ tests/world-id-analytics/end-to-end-release.test.ts \ --runInBand +npx jest tests/world-id-analytics/integration.test.ts --runInBand