Files
buzz-sheet/tests/analytics.integration.test.ts
T
gunshiz a3803118ef
CI / Verify (push) Successful in 2m10s
CI / Build immutable images and deploy (push) Successful in 2m33s
feat : add analytics page
2026-10-10 20:19:49 +07:00

178 lines
12 KiB
TypeScript

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<ReturnType<typeof getPublicAnalytics>>;
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);
}
});
});