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
175 changes: 175 additions & 0 deletions src/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @jest-environment node
*/
import { NextRequest } from "next/server";
import {
POST as postEvent,
GET as getEvents,
} from "@/app/api/v1/analytics/events/route";

// ── helpers ────────────────────────────────────────────────────────────────

function makeRequest(
url: string,
options: { method?: string; body?: string; headers?: Record<string, string> } = {},
) {
return new NextRequest(new URL(url, "http://localhost"), {
method: options.method ?? "GET",
body: options.body,
headers: { "Content-Type": "application/json", ...options.headers },
});
}

function validEvent(overrides: Record<string, unknown> = {}) {
return {
id: `evt_${Date.now()}_0`,
name: "listing_view",
timestamp: new Date().toISOString(),
...overrides,
};
}

// ── POST /api/v1/analytics/events ──────────────────────────────────────────

describe("POST /api/v1/analytics/events", () => {
it("records a valid event and returns 200", async () => {
const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(validEvent()),
});

const res = await postEvent(req);
expect(res.status).toBe(200);

const json = await res.json();
expect(json.data.recorded).toBe(true);
expect(json.data.eventId).toBeDefined();
});

it("accepts all valid event names", async () => {
const names = [
"listing_view",
"listing_create",
"trade_complete",
"match_propose",
"wallet_connect",
"wallet_disconnect",
"page_view",
];

for (const name of names) {
const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(validEvent({ name })),
});
const res = await postEvent(req);
expect(res.status).toBe(200);
}
});

it("rejects missing id", async () => {
const event = validEvent();
delete (event as Record<string, unknown>).id;

const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(event),
});

const res = await postEvent(req);
expect(res.status).toBe(400);
});

it("rejects an unknown event name", async () => {
const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(validEvent({ name: "hack_attempt" })),
});

const res = await postEvent(req);
expect(res.status).toBe(400);
});

it("rejects a malformed timestamp", async () => {
const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(validEvent({ timestamp: "not-a-date" })),
});

const res = await postEvent(req);
expect(res.status).toBe(400);
});

it("rejects non-JSON body", async () => {
const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: "not-json",
});

const res = await postEvent(req);
expect(res.status).toBe(400);
});

it("accepts an event with optional properties", async () => {
const req = makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(
validEvent({ properties: { assetId: "gold", price: 2385 } }),
),
});

const res = await postEvent(req);
expect(res.status).toBe(200);
});
});

// ── GET /api/v1/analytics/events ───────────────────────────────────────────

describe("GET /api/v1/analytics/events", () => {
it("returns a list of events", async () => {
// Record one first
await postEvent(
makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(validEvent({ name: "trade_complete" })),
}),
);

const req = makeRequest("http://localhost/api/v1/analytics/events");
const res = await getEvents(req);
expect(res.status).toBe(200);

const json = await res.json();
expect(Array.isArray(json.data)).toBe(true);
expect(json.metadata.total).toBeGreaterThan(0);
});

it("respects the limit parameter", async () => {
const req = makeRequest(
"http://localhost/api/v1/analytics/events?limit=2",
);
const res = await getEvents(req);
const json = await res.json();
expect(json.data.length).toBeLessThanOrEqual(2);
});

it("filters by event name", async () => {
// Record a wallet_connect event
await postEvent(
makeRequest("http://localhost/api/v1/analytics/events", {
method: "POST",
body: JSON.stringify(validEvent({ name: "wallet_connect" })),
}),
);

const req = makeRequest(
"http://localhost/api/v1/analytics/events?name=wallet_connect",
);
const res = await getEvents(req);
const json = await res.json();

for (const event of json.data) {
expect(event.name).toBe("wallet_connect");
}
});
});
109 changes: 109 additions & 0 deletions src/__tests__/analyticsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @jest-environment jsdom
*/

// analyticsService calls fetch — stub it before importing the module.
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch;

import {
aggregateMetrics,
getBufferedEvents,
isOptedOut,
optIn,
optOut,
trackEvent,
} from "@/services/analyticsService";

beforeEach(() => {
// Reset localStorage and the fetch spy between tests.
localStorage.clear();
mockFetch.mockClear();
// Clear the internal buffer by re-importing is tricky; instead we drain it
// by reading and ignoring — real isolation would require module reset, but
// verifying the opt-out and aggregation logic is sufficient here.
});

describe("opt-out preference", () => {
it("isOptedOut returns false by default", () => {
expect(isOptedOut()).toBe(false);
});

it("optOut persists the preference", () => {
optOut();
expect(isOptedOut()).toBe(true);
});

it("optIn clears the preference", () => {
optOut();
optIn();
expect(isOptedOut()).toBe(false);
});
});

describe("trackEvent", () => {
it("does not call fetch when opted out", async () => {
optOut();
await trackEvent("listing_view", { assetId: "gold" });
expect(mockFetch).not.toHaveBeenCalled();
optIn();
});

it("calls fetch when opted in", async () => {
optIn();
await trackEvent("page_view", { page: "/analytics" });
expect(mockFetch).toHaveBeenCalledWith(
"/api/v1/analytics/events",
expect.objectContaining({ method: "POST" }),
);
});

it("adds events to the buffer", async () => {
optIn();
const before = getBufferedEvents().length;
await trackEvent("trade_complete", { asset: "XLM" });
expect(getBufferedEvents().length).toBeGreaterThan(before);
});

it("stored event has expected shape", async () => {
optIn();
await trackEvent("wallet_connect", { wallet: "GTEST123" });
const events = getBufferedEvents();
const last = events[events.length - 1];
expect(last.name).toBe("wallet_connect");
expect(last.id).toMatch(/^evt_/);
expect(last.timestamp).toBeTruthy();
expect(last.properties?.wallet).toBe("GTEST123");
});
});

describe("aggregateMetrics", () => {
it("returns zero counts when no events are buffered (7-day window)", () => {
// Use 0-day window to get an empty snapshot regardless of prior tests
const metrics = aggregateMetrics(0);
expect(metrics.tradesCompleted).toBeGreaterThanOrEqual(0);
expect(metrics.listingsViewed).toBeGreaterThanOrEqual(0);
expect(metrics.matchesProposed).toBeGreaterThanOrEqual(0);
expect(Array.isArray(metrics.dailyActivity)).toBe(true);
});

it("dailyActivity has an entry per day for the requested window", async () => {
const days = 7;
const metrics = aggregateMetrics(days);
expect(metrics.dailyActivity).toHaveLength(days);
});

it("counts trade_complete events in the window", async () => {
optIn();
// Track events so we know exactly what's in the buffer going forward
await trackEvent("trade_complete");
await trackEvent("trade_complete");
await trackEvent("listing_view");

const metrics = aggregateMetrics(7);
// We can't assert exact counts because other tests also pushed events,
// but we can confirm trades ≥ 2 and listings ≥ 1
expect(metrics.tradesCompleted).toBeGreaterThanOrEqual(2);
expect(metrics.listingsViewed).toBeGreaterThanOrEqual(1);
});
});
17 changes: 17 additions & 0 deletions src/app/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { AnalyticsDashboard } from "@/components/AnalyticsDashboard";

export const metadata: Metadata = {
title: "Analytics · InterChangableTrade",
description:
"Platform engagement metrics — active wallets, trading volume, and listing activity.",
};

/**
* Analytics & Metrics dashboard page.
* All data rendering is delegated to the AnalyticsDashboard client component
* so that real-time updates and browser-local opt-out preferences work correctly.
*/
export default function AnalyticsPage() {
return <AnalyticsDashboard />;
}
Loading
Loading