Files
petfeed/src/__tests__/explore.test.ts
T
admin 1ee2b53a57 feat(species): expand taxonomy to 14 species, add Arachnophobie-Helfer
Add Horse, Rabbit, Guinea Pig, Hamster, Mouse, Rat, Snake, Lizard, Turtle,
Gecko, and Spider as Species (with breed taxonomies + DE translations),
alongside the existing Dog/Cat/Bird. The species/breed system was already
fully data-driven (pet creation, onboarding, ad targeting all read
pets.listSpecies/listBreeds dynamically), so this is mostly seed data.

Spiders get dedicated handling: an opt-in Owner.hideSpiders preference
(offered once during onboarding via ArachnophobiaOptIn, always
re-toggleable on /pets via HideSpidersToggle) fully excludes Species=Spider
pets/posts from feed, explore, and search when enabled.
2026-08-15 11:57:24 +02:00

159 lines
5.6 KiB
TypeScript

/**
* Explore Unit Tests — Phase 3 Plan 03-05
*
* Covers: EXPL-01 (explore page — browse pets by species/breed)
*
* Tests:
* - listPets returns all pets when no filter
* - listPets filters by speciesId
* - cursor pagination: returns nextCursor when more results exist
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
}));
vi.mock("@/lib/redis", () => ({ redis: {} }));
vi.mock("@/lib/feed-helpers", () => ({
fanOutPost: vi.fn().mockResolvedValue(undefined),
backfillOnFollow: vi.fn().mockResolvedValue(undefined),
feedKey: vi.fn((id: string) => `feed:${id}`),
getFeedPage: vi.fn(),
trimFeed: vi.fn(),
PAGE_SIZE: 20,
FEED_RETENTION_MS: 90 * 24 * 60 * 60 * 1000,
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
async function createExploreCaller(overrides?: {
prisma?: ReturnType<typeof createMockPrisma>;
}) {
const { exploreRouter } = await import("@/trpc/routers/explore");
const { initTRPC } = await import("@trpc/server");
const mockPrisma = overrides?.prisma ?? createMockPrisma();
const t = initTRPC
.context<{ userId: string | null; prisma: typeof mockPrisma }>()
.create();
const testRouter = t.router({ explore: exploreRouter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({ userId: "owner-1", prisma: mockPrisma as any });
return { caller, mockPrisma };
}
const makePet = (id: string, speciesId: string) => ({
id,
name: `Pet ${id}`,
speciesId,
breedId: null,
avatarKey: null,
ownerId: "owner-1",
createdAt: new Date(),
species: { id: speciesId, name: "Dog" },
breed: null,
_count: { followers: 10 },
});
// ─── explore.listPets ────────────────────────────────────────────────────────
describe("explore.listPets", () => {
it("EXPL-01 — returns all pets when no filter applied", async () => {
const { caller, mockPrisma } = await createExploreCaller();
const pets = [makePet("pet-1", "species-dog"), makePet("pet-2", "species-cat")];
mockPrisma.pet.findMany.mockResolvedValue(pets);
const result = await caller.explore.listPets({ limit: 20 });
expect(result.pets).toHaveLength(2);
expect(result.nextCursor).toBeNull();
});
it("EXPL-01 — filters by speciesId when provided", async () => {
const { caller, mockPrisma } = await createExploreCaller();
const pets = [makePet("pet-1", "species-dog")];
mockPrisma.pet.findMany.mockResolvedValue(pets);
const result = await caller.explore.listPets({ speciesId: "species-dog", limit: 20 });
expect(mockPrisma.pet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ speciesId: "species-dog" }),
})
);
expect(result.pets).toHaveLength(1);
});
it("Arachnophobie-Helfer: excludes Species=Spider pets when the caller opted in", async () => {
const { caller, mockPrisma } = await createExploreCaller();
mockPrisma.owner.findUnique.mockResolvedValue({ hideSpiders: true });
mockPrisma.species.findUnique.mockResolvedValue({ id: "species-spider" });
mockPrisma.pet.findMany.mockResolvedValue([makePet("pet-1", "species-dog")]);
await caller.explore.listPets({ limit: 20 });
expect(mockPrisma.pet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ NOT: { speciesId: "species-spider" } }),
})
);
});
it("EXPL-01 — cursor pagination: returns nextCursor when more results exist", async () => {
const { caller, mockPrisma } = await createExploreCaller();
// Return limit+1 pets to trigger nextCursor
const pets = Array.from({ length: 21 }, (_, i) => makePet(`pet-${i}`, "species-dog"));
mockPrisma.pet.findMany.mockResolvedValue(pets);
const result = await caller.explore.listPets({ limit: 20 });
expect(result.pets).toHaveLength(20);
expect(result.nextCursor).not.toBeNull();
});
});
// ─── explore.getTrending ─────────────────────────────────────────────────────
describe("explore.getTrending", () => {
it("queries pinned-or-recent posts, sorted pinned-first then by reactionCount", async () => {
const { caller, mockPrisma } = await createExploreCaller();
mockPrisma.post.findMany.mockResolvedValue([]);
mockPrisma.hashtag.findMany.mockResolvedValue([]);
await caller.explore.getTrending();
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
hiddenAt: null,
type: "PHOTO",
OR: [
{ createdAt: { gte: expect.any(Date) } },
{ pinnedAt: { not: null } },
],
}),
orderBy: [{ pinnedAt: { sort: "desc", nulls: "last" } }, { reactionCount: "desc" }],
})
);
});
it("returns both posts and hashtags", async () => {
const { caller, mockPrisma } = await createExploreCaller();
mockPrisma.post.findMany.mockResolvedValue([{ id: "post-1" }]);
mockPrisma.hashtag.findMany.mockResolvedValue([{ id: "tag-1", tag: "cute" }]);
const result = await caller.explore.getTrending();
expect(result.posts).toEqual([{ id: "post-1" }]);
expect(result.hashtags).toEqual([{ id: "tag-1", tag: "cute" }]);
});
});