import { randomUUID } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); const { closeDb, getDb } = await import("@/db"); const schema = await import("@/db/schema"); const { getPublicAnalytics } = await import("@/lib/analytics/queries"); const { selectAnalytics } = await import("@/lib/analytics/model"); const databaseUrl = process.env.DATABASE_INTEGRATION_URL; const describeWithDatabase = databaseUrl ? describe : describe.skip; describeWithDatabase("public analytics aggregation", () => { const prefix = randomUUID(); const guideIds = Array.from({ length: 4 }, () => randomUUID()); const mediaId = randomUUID(); const characterKey = `char-${prefix}`; const missingKey = `missing-${prefix}`; const weaponKey = `weapon-${prefix}`; const artifactKey = `artifact-${prefix}`; const mixedKey = `mixed-${prefix}`; const scheduleIds = [2_000_000_000 + Math.floor(Math.random() * 100_000), 2_000_200_000 + Math.floor(Math.random() * 100_000)]; const originalDatabaseUrl = process.env.DATABASE_URL; const originalPublicUrl = process.env.S3_PUBLIC_URL; let baseline: Awaited>; beforeAll(async () => { process.env.DATABASE_URL = databaseUrl; process.env.S3_PUBLIC_URL = "https://media.example.test"; const db = getDb(); baseline = await getPublicAnalytics(); await db.insert(schema.catalogCharacters).values({ key: characterKey, name: "Test Character", imageKey: "character.png", rarity: 5 }); await db.insert(schema.catalogWeapons).values({ key: weaponKey, name: "Test Weapon", imageKey: "weapon.png", rarity: 3 }); await db.insert(schema.catalogArtifacts).values([ { key: artifactKey, name: "Test Artifact", imageKey: "artifact.png", rarity: 4 }, { key: mixedKey, name: "Mixed Artifact", imageKey: "mixed.png" }, ]); await db.insert(schema.media).values({ id: mediaId, objectKey: `test/${prefix}.png`, fileName: "test.png", mimeType: "image/png", byteSize: 1, status: "ready" }); for (const [index, id] of guideIds.entries()) { // Public, hidden, trashed, and public with disabled sections. await db.insert(schema.guides).values({ id, characterKey: `${prefix}-${index}`, slug: `${prefix}-${index}`, name: "Test Guide", overview: "", coverMediaId: mediaId, isPublic: index !== 1, trashedAt: index === 2 ? new Date() : null, }); await db.insert(schema.guideSections).values(["team", "weapon", "artifact"].map((kind) => ({ guideId: id, kind, slug: kind, title: kind, enabled: index !== 3, }))); // A second enabled section of the same kind must not multiply counts. if (index === 0) await db.insert(schema.guideSections).values({ guideId: id, kind: "team", slug: "other-team", title: "Other team" }); const teamCount = index === 0 ? 2 : 1; for (let variant = 0; variant < teamCount; variant++) { const [team] = await db.insert(schema.teamCompositions).values({ guideId: id, title: "Test", combo: "", rotationSeconds: "20" }).returning(); await db.insert(schema.teamMembers).values({ teamId: team.id, position: 0, characterKey, weaponKey, artifactKey, secondArtifactKey: variant === 0 ? mixedKey : artifactKey, damage: "1", }); } const [weaponCondition] = await db.insert(schema.weaponConditions).values({ guideId: id }).returning(); await db.insert(schema.weaponRecommendations).values(["1", "5"].map((refinement) => ({ conditionId: weaponCondition.id, weaponKey, refinement }))); const [artifactCondition] = await db.insert(schema.artifactConditions).values({ guideId: id }).returning(); const [artifactGroup] = await db.insert(schema.artifactGroups).values({ conditionId: artifactCondition.id, name: "Test" }).returning(); await db.insert(schema.artifactRecommendations).values([mixedKey, artifactKey].map((secondArtifactKey) => ({ groupId: artifactGroup.id, firstArtifactKey: artifactKey, secondArtifactKey, percent: "100", }))); } for (const [index, scheduleId] of scheduleIds.entries()) { await db.insert(schema.stygianSchedules).values({ scheduleId, challengeName: "Test", scheduleStartTime: new Date(index ? "2099-01-01" : "2020-01-01"), scheduleEndTime: new Date(index ? "2099-02-01" : "2020-02-01"), }); const [team] = await db.insert(schema.stygianRecommendationGroups).values({ scheduleId, level: index ? 110 : 105, bossPosition: index, kind: "team" }).returning(); await db.insert(schema.stygianRecommendationItems).values([ { groupId: team.id, position: index ? 1 : 0, kind: "character", catalogKey: characterKey, weaponKey, artifactKey }, { groupId: team.id, position: index ? 0 : 1, kind: "character", catalogKey: missingKey }, ]); if (index === 0) { for (const kind of ["weapon", "artifact", "character", "element"]) { const [unit] = await db.insert(schema.stygianRecommendationGroups).values({ scheduleId, level: 105, bossPosition: 0, kind: "unit" }).returning(); await db.insert(schema.stygianRecommendationItems).values({ groupId: unit.id, position: 0, kind, catalogKey: kind === "weapon" ? weaponKey : kind === "artifact" ? artifactKey : kind === "character" ? characterKey : null, weaponKey: kind === "character" ? weaponKey : null, artifactKey: kind === "character" ? artifactKey : null, element: kind === "element" ? "Pyro" : null, }); } } } }, 30_000); afterAll(async () => { const db = getDb(); await db.delete(schema.stygianSchedules).where(inArray(schema.stygianSchedules.scheduleId, scheduleIds)); await db.delete(schema.guides).where(inArray(schema.guides.id, guideIds)); await db.delete(schema.media).where(eq(schema.media.id, mediaId)); await db.delete(schema.catalogCharacters).where(eq(schema.catalogCharacters.key, characterKey)); await db.delete(schema.catalogWeapons).where(eq(schema.catalogWeapons.key, weaponKey)); await db.delete(schema.catalogArtifacts).where(inArray(schema.catalogArtifacts.key, [artifactKey, mixedKey])); await closeDb(); if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL; else process.env.DATABASE_URL = originalDatabaseUrl; if (originalPublicUrl === undefined) delete process.env.S3_PUBLIC_URL; else process.env.S3_PUBLIC_URL = originalPublicUrl; }); it("counts variants, mixed sets, and both Stygian versions while excluding nonpublic content", async () => { const data = await getPublicAnalytics(); expect(data.guideCount).toBe(baseline.guideCount + 2); expect(data.guideTeamCount).toBe(baseline.guideTeamCount + 2); expect(data.stygianScheduleCount).toBe(baseline.stygianScheduleCount + 2); expect(data.stygianTeamCount).toBe(baseline.stygianTeamCount + 2); expect(data.team.character.find(({ key }) => key === characterKey)).toMatchObject({ guideCount: 2, stygianCount: 2, rarity: 5 }); expect(data.team.weapon.find(({ key }) => key === weaponKey)).toMatchObject({ guideCount: 2, stygianCount: 2, rarity: 3 }); expect(data.team.artifact.find(({ key }) => key === artifactKey)).toMatchObject({ guideCount: 2, stygianCount: 2, rarity: 4 }); expect(data.team.artifact.find(({ key }) => key === mixedKey)).toMatchObject({ guideCount: 1, stygianCount: 0 }); }); it("keeps standalone recommendations separate from teams and ignores elements and null equipment", async () => { const data = await getPublicAnalytics(); expect(data.recommendation.weapon.find(({ key }) => key === weaponKey)).toMatchObject({ guideCount: 2, stygianCount: 2 }); expect(data.recommendation.artifact.find(({ key }) => key === artifactKey)).toMatchObject({ guideCount: 2, stygianCount: 2 }); expect(data.recommendation.artifact.find(({ key }) => key === mixedKey)).toMatchObject({ guideCount: 1, stygianCount: 0 }); expect(data.team.character.find(({ key }) => key === missingKey)).toMatchObject({ name: missingKey, imageUrl: null, rarity: null, guideCount: 0, stygianCount: 2 }); const excluded = selectAnalytics(data, false); expect(excluded.team.character.some(({ key }) => key === missingKey)).toBe(false); expect(excluded.recommendation.weapon.find(({ key }) => key === weaponKey)?.count).toBe(2); expect(data.team.weapon.some(({ key }) => !key)).toBe(false); }); it("reflects visibility and section changes on the next aggregate read", async () => { const db = getDb(); try { await db.update(schema.guides).set({ isPublic: false }).where(eq(schema.guides.id, guideIds[0])); const hidden = await getPublicAnalytics(); expect(hidden.team.character.find(({ key }) => key === characterKey)?.guideCount).toBe(0); expect(hidden.recommendation.weapon.find(({ key }) => key === weaponKey)?.guideCount).toBe(0); await db.update(schema.guides).set({ isPublic: true }).where(eq(schema.guides.id, guideIds[0])); await db.update(schema.guideSections).set({ enabled: false }).where(eq(schema.guideSections.guideId, guideIds[0])); const disabled = await getPublicAnalytics(); expect(disabled.guideTeamCount).toBe(baseline.guideTeamCount); expect(disabled.recommendation.artifact.find(({ key }) => key === artifactKey)?.guideCount).toBe(0); } finally { await db.update(schema.guides).set({ isPublic: true }).where(eq(schema.guides.id, guideIds[0])); await db.update(schema.guideSections).set({ enabled: true }).where(eq(schema.guideSections.guideId, guideIds[0])); } }); it("deduplicates identical builds across guides and Stygian after guide exclusions, preserving build variants", async () => { const db = getDb(); const section = and(eq(schema.guideSections.guideId, guideIds[3]), eq(schema.guideSections.kind, "team")); try { await db.update(schema.guideSections).set({ enabled: true }).where(section); const data = await getPublicAnalytics(); const unique = selectAnalytics(data, true, new Set(), true); expect(unique.teamCount).toBe(selectAnalytics(baseline, true, new Set(), true).teamCount + 3); expect(unique.team.character.find(({ key }) => key === characterKey)?.count).toBe(3); const excludeOriginal = selectAnalytics(data, true, new Set([`guide:${guideIds[0]}`]), true); expect(excludeOriginal.team.character.find(({ key }) => key === characterKey)?.count).toBe(2); expect(excludeOriginal.recommendation.weapon.find(({ key }) => key === weaponKey)?.count).toBe(2); const guideOnly = selectAnalytics(data, false, new Set([`guide:${guideIds[0]}`]), true); expect(guideOnly.team.character.find(({ key }) => key === characterKey)?.count).toBe(1); const excludedSchedule = selectAnalytics(data, true, new Set([`stygian:${scheduleIds[0]}`]), true); expect(excludedSchedule.team.character.find(({ key }) => key === characterKey)?.count).toBe(3); expect(excludedSchedule.recommendation.weapon.find(({ key }) => key === weaponKey)?.count).toBe(2); const copyTeams = await db.select({ id: schema.teamCompositions.id }).from(schema.teamCompositions).where(eq(schema.teamCompositions.guideId, guideIds[3])); await db.update(schema.teamMembers).set({ constellation: "1" }).where(inArray(schema.teamMembers.teamId, copyTeams.map(({ id }) => id))); const changedBuild = selectAnalytics(await getPublicAnalytics(), true, new Set(), true); expect(changedBuild.team.character.find(({ key }) => key === characterKey)?.count).toBe(4); } finally { await db.update(schema.guideSections).set({ enabled: false }).where(section); } }); });