Fas 1a: product analytics schema + GDPR controls

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:32 +07:00
parent ac5340195a
commit d627014425
26 changed files with 9039 additions and 14 deletions
@@ -0,0 +1,105 @@
/**
* GDPR helpers for product analytics integration tests.
* Assumes DATABASE_URL points to a writable database.
*/
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import { createDatabase, closeDatabase, schema, isAnalyticsOptedIn, deleteUserAnalyticsEvents } from "@app/database";
const TEST_USER_EMAIL = "analytics-gdpr-test@example.invalid";
describe("analytics GDPR helpers", () => {
const { db, pool } = createDatabase();
let userId: string;
beforeAll(async () => {
// Clean up any stale test user.
await db.delete(schema.users).where(eq(schema.users.email, TEST_USER_EMAIL));
const [user] = await db
.insert(schema.users)
.values({
email: TEST_USER_EMAIL,
displayName: "Analytics Test",
locale: "sv-SE",
})
.returning({ id: schema.users.id });
userId = user!.id;
});
afterAll(async () => {
await db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId));
await db.delete(schema.users).where(eq(schema.users.id, userId));
await closeDatabase();
});
it("missing consent row → opted in (legitimate interest, opt-out)", async () => {
await db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId));
expect(await isAnalyticsOptedIn(db, userId)).toBe(true);
});
it("explicit granted → opted in", async () => {
await db
.insert(schema.userConsents)
.values({
userId,
kind: "product_analytics",
status: "granted",
grantedAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: { status: "granted", revokedAt: null, updatedAt: new Date() },
});
expect(await isAnalyticsOptedIn(db, userId)).toBe(true);
});
it("explicit revoked → opted out", async () => {
const now = new Date();
await db
.insert(schema.userConsents)
.values({
userId,
kind: "product_analytics",
status: "revoked",
revokedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: { status: "revoked", revokedAt: now, updatedAt: now },
});
expect(await isAnalyticsOptedIn(db, userId)).toBe(false);
});
it("deleteUserAnalyticsEvents removes only the target user's events", async () => {
// Insert a dummy event for the test user and another user.
const [otherUser] = await db
.insert(schema.users)
.values({ email: "other-analytics-test@example.invalid", displayName: "Other", locale: "sv-SE" })
.returning({ id: schema.users.id });
await db.insert(schema.productAnalyticsEvents).values({
userId,
eventName: "app_first_open",
anonymousId: "anon-test",
});
await db.insert(schema.productAnalyticsEvents).values({
userId: otherUser!.id,
eventName: "app_first_open",
anonymousId: "anon-other",
});
const deleted = await deleteUserAnalyticsEvents(db, userId);
expect(deleted).toBe(1);
const remaining = await db
.select({ id: schema.productAnalyticsEvents.id })
.from(schema.productAnalyticsEvents)
.where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
expect(remaining).toHaveLength(1);
await db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
await db.delete(schema.users).where(eq(schema.users.id, otherUser!.id));
});
});