178 lines
12 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|