Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ HEALTHCHECKS_REPORTS_DIGEST_URL=
HEALTHCHECKS_BACKUP_URL=
HEALTHCHECKS_INACTIVE_ACCOUNTS_SCAN_URL=
HEALTHCHECKS_GAMIFICATION_RECONCILE_URL=
HEALTHCHECKS_GAMIFICATION_ACHIEVEMENTS_SWEEP_URL=
HEALTHCHECKS_OFFSITE_BACKUP_URL=

# --- Public hosting on a VPS (see README "Public hosting (VPS)") ---
Expand Down
1 change: 1 addition & 0 deletions apps/api/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["package.json"],
"plugins": ["@nestjs/swagger"],
"manualRestart": false,
"builder": "tsc",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "UserAchievement" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"unlockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"displayedAt" TIMESTAMP(3),

CONSTRAINT "UserAchievement_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "UserAchievement_userId_key_key" ON "UserAchievement"("userId", "key");

-- AddForeignKey
ALTER TABLE "UserAchievement" ADD CONSTRAINT "UserAchievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
27 changes: 27 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ model User {
subscriptions Subscription[]
xpEntries XpEntry[]
score UserScore?
achievements UserAchievement[]

@@index([lastActiveAt])
}
Expand Down Expand Up @@ -1668,3 +1669,29 @@ model UserScore {

@@index([xp])
}

// One row per unlocked achievement. Definitions live in code
// (apps/api/src/gamification/achievements/registry.ts), never in the
// database — a self-host install must never need seeding. Achievements are
// never revoked once unlocked, unlike XpEntry: a trophy isn't taken back
// because you later removed the title that earned it.
model UserAchievement {
id String @id @default(cuid())
userId String
// Stable key matching a registry entry — string, not a Prisma enum, so a
// new achievement needs no migration (same rationale as XpEntry.reason).
// Each tier of a tiered achievement is its own key (e.g.
// "cinephile_bronze"/"cinephile_silver"/"cinephile_gold"), not a shared
// row with a tier column.
key String
unlockedAt DateTime @default(now())
// Null until the [G6] unlock-bubble UI has shown this achievement to the
// user once — set by PATCH /achievements/:id/displayed. Independent of
// unlockedAt: an unlock can happen server-side (a live action, or the
// nightly sweep) while the user isn't even in the app.
displayedAt DateTime?

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@unique([userId, key])
}
2 changes: 1 addition & 1 deletion apps/api/src/config/public-config.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class PublicConfigController {
@Get()
@ApiOkResponse({ type: PublicConfigResponseDto })
async get(): Promise<PublicConfigDto> {
const raw = await readFile(join(process.cwd(), "package.json"), "utf-8");
const raw = await readFile(join(__dirname, "../../package.json"), "utf-8");
const { version } = JSON.parse(raw) as { version: string };

return {
Expand Down
250 changes: 250 additions & 0 deletions apps/api/src/gamification/achievements/achievement.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import type { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";

Check warning on line 2 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L2

Added line #L2 was not covered by tests
import { vi, type Mock } from "vitest";
import type { FeatureFlagsService } from "../../feature-flags/feature-flags.service";
import type { JobRunService } from "../../jobs/job-run.service";
import type { PrismaService } from "../../prisma/prisma.service";
import type { XpService } from "../xp.service";
import { AchievementService } from "./achievement.service";

Check warning on line 8 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L8

Added line #L8 was not covered by tests

// A socialGated achievement to exercise the [G3]-reserved gate — none of
// this ticket's own registry entries (first_episode, cinephile_*) use it.
const { socialGatedCheck } = vi.hoisted(() => ({

Check warning on line 12 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L12

Added line #L12 was not covered by tests
socialGatedCheck: vi.fn(),
}));

vi.mock("./registry", async (importOriginal) => {
const actual = await importOriginal<typeof import("./registry")>();
const socialGatedDefinition = {

Check warning on line 18 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L16-L18

Added lines #L16 - L18 were not covered by tests
key: "test_social_gated",
xpAward: 10,
socialGated: true,
check: socialGatedCheck,
};
return {

Check warning on line 24 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L24

Added line #L24 was not covered by tests
...actual,
ACHIEVEMENTS: {
...actual.ACHIEVEMENTS,
test_social_gated: socialGatedDefinition,
},
ACHIEVEMENT_LIST: [...actual.ACHIEVEMENT_LIST, socialGatedDefinition],
};
});

function uniqueConstraintError(): Prisma.PrismaClientKnownRequestError {
return new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {

Check warning on line 35 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L35

Added line #L35 was not covered by tests
code: "P2002",
clientVersion: "test",
});
}

function makeConfig(values: Record<string, string> = {}): ConfigService {
return {
get: vi.fn((key: string) => values[key]),

Check warning on line 43 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L42-L43

Added lines #L42 - L43 were not covered by tests
} as unknown as ConfigService;
}

// No Unleash client configured in tests — isEnabled always returns whatever
// fallback the caller passed, same convention as xp.service.spec.ts.
function makeFlags(): FeatureFlagsService {
return {
isEnabled: vi.fn((_name: string, fallback: boolean) => fallback),

Check warning on line 51 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L50-L51

Added lines #L50 - L51 were not covered by tests
} as unknown as FeatureFlagsService;
}

function makeService(configValues: Record<string, string> = {}) {
const prisma = {

Check warning on line 56 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L56

Added line #L56 was not covered by tests
userAchievement: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "achievement-1" }),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
episodeWatch: { findFirst: vi.fn().mockResolvedValue(null) },
libraryEntry: { count: vi.fn().mockResolvedValue(0) },
xpEntry: { findMany: vi.fn().mockResolvedValue([]) },
user: { findMany: vi.fn().mockResolvedValue([]) },
} as unknown as PrismaService;
const config = makeConfig({ GAMIFICATION_ENABLED: "true", ...configValues });
const flags = makeFlags();
const xp = { award: vi.fn() } as unknown as XpService;
const jobRuns = {
record: vi.fn((_key: string, fn: () => Promise<unknown>) => fn()),

Check warning on line 72 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L68-L72

Added lines #L68 - L72 were not covered by tests
} as unknown as JobRunService;

const service = new AchievementService(prisma, config, flags, xp, jobRuns);
return { service, prisma, config, flags, xp, jobRuns };

Check warning on line 76 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L75-L76

Added lines #L75 - L76 were not covered by tests
}

describe("AchievementService.evaluate", () => {
it("unlocks a satisfied achievement, credits its xpAward via amountOverride, sourced from the created row's id", async () => {
const { service, prisma, xp } = makeService();
(prisma.episodeWatch.findFirst as Mock).mockResolvedValue({ id: "w1" });

Check warning on line 82 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L79-L82

Added lines #L79 - L82 were not covered by tests

await service.evaluate("user-1", ["first_episode"]);

Check warning on line 84 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L84

Added line #L84 was not covered by tests

expect(prisma.userAchievement.create).toHaveBeenCalledWith({

Check warning on line 86 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L86

Added line #L86 was not covered by tests
data: { userId: "user-1", key: "first_episode" },
});
expect(xp.award).toHaveBeenCalledWith(

Check warning on line 89 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L89

Added line #L89 was not covered by tests
"user-1",
"ACHIEVEMENT_UNLOCKED",
"achievement-1",
50,
);
});

it("does not even call check() when the achievement is already unlocked", async () => {
const { service, prisma } = makeService();
(prisma.userAchievement.findUnique as Mock).mockResolvedValue({

Check warning on line 99 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L97-L99

Added lines #L97 - L99 were not covered by tests
id: "achievement-1",
});

await service.evaluate("user-1", ["first_episode"]);

Check warning on line 103 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L103

Added line #L103 was not covered by tests

expect(prisma.episodeWatch.findFirst).not.toHaveBeenCalled();
expect(prisma.userAchievement.create).not.toHaveBeenCalled();

Check warning on line 106 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L105-L106

Added lines #L105 - L106 were not covered by tests
});

it("stays locked when the check reports unlocked: false", async () => {
const { service, prisma, xp } = makeService();
(prisma.episodeWatch.findFirst as Mock).mockResolvedValue(null);

Check warning on line 111 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L109-L111

Added lines #L109 - L111 were not covered by tests

await service.evaluate("user-1", ["first_episode"]);

Check warning on line 113 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L113

Added line #L113 was not covered by tests

expect(prisma.userAchievement.create).not.toHaveBeenCalled();
expect(xp.award).not.toHaveBeenCalled();

Check warning on line 116 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L115-L116

Added lines #L115 - L116 were not covered by tests
});

it("no-ops entirely (no read, no write) when GAMIFICATION_ENABLED is off", async () => {
const { service, prisma } = makeService({ GAMIFICATION_ENABLED: "false" });

Check warning on line 120 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L119-L120

Added lines #L119 - L120 were not covered by tests

await service.evaluate("user-1");

Check warning on line 122 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L122

Added line #L122 was not covered by tests

expect(prisma.userAchievement.findUnique).not.toHaveBeenCalled();

Check warning on line 124 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L124

Added line #L124 was not covered by tests
});

it("never unlocks a socialGated achievement while SOCIAL_ENABLED is off", async () => {
const { service, prisma, xp } = makeService();
socialGatedCheck.mockResolvedValue({ unlocked: true });

Check warning on line 129 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L127-L129

Added lines #L127 - L129 were not covered by tests

await service.evaluate("user-1", ["test_social_gated"]);

Check warning on line 131 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L131

Added line #L131 was not covered by tests

expect(socialGatedCheck).not.toHaveBeenCalled();
expect(prisma.userAchievement.create).not.toHaveBeenCalled();
expect(xp.award).not.toHaveBeenCalled();

Check warning on line 135 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L133-L135

Added lines #L133 - L135 were not covered by tests
});

it("credits no XP when create() hits the unique constraint (concurrent unlock)", async () => {
const { service, prisma, xp } = makeService();
(prisma.episodeWatch.findFirst as Mock).mockResolvedValue({ id: "w1" });
(prisma.userAchievement.create as Mock).mockRejectedValue(

Check warning on line 141 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L138-L141

Added lines #L138 - L141 were not covered by tests
uniqueConstraintError(),
);

await expect(

Check warning on line 145 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L145

Added line #L145 was not covered by tests
service.evaluate("user-1", ["first_episode"]),
).resolves.toBeUndefined();
expect(xp.award).not.toHaveBeenCalled();

Check warning on line 148 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L148

Added line #L148 was not covered by tests
});

it("evaluates only the registry entries named by `keys`", async () => {
const { service, prisma } = makeService();

Check warning on line 152 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L151-L152

Added lines #L151 - L152 were not covered by tests

await service.evaluate("user-1", ["first_episode"]);

Check warning on line 154 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L154

Added line #L154 was not covered by tests

// Only first_episode's own uniqueness check runs — cinephile's tiers
// and the socialGated test fixture are skipped entirely.
expect(prisma.userAchievement.findUnique).toHaveBeenCalledTimes(1);
expect(prisma.userAchievement.findUnique).toHaveBeenCalledWith({

Check warning on line 159 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L158-L159

Added lines #L158 - L159 were not covered by tests
where: { userId_key: { userId: "user-1", key: "first_episode" } },
select: { id: true },
});
});
});

describe("AchievementService.pending", () => {
it("returns unlocked-but-undisplayed achievements, oldest first, with xpAwarded looked up from the matching XpEntry", async () => {
const { service, prisma } = makeService();
(prisma.userAchievement.findMany as Mock).mockResolvedValue([

Check warning on line 169 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L166-L169

Added lines #L166 - L169 were not covered by tests
{
id: "achievement-1",
key: "first_episode",
unlockedAt: new Date("2026-01-01T00:00:00.000Z"),
},
]);
(prisma.xpEntry.findMany as Mock).mockResolvedValue([

Check warning on line 176 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L176

Added line #L176 was not covered by tests
{ sourceId: "achievement-1", amount: 50 },
]);

await expect(service.pending("user-1")).resolves.toEqual([

Check warning on line 180 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L180

Added line #L180 was not covered by tests
{
id: "achievement-1",
key: "first_episode",
unlockedAt: "2026-01-01T00:00:00.000Z",
xpAwarded: 50,
},
]);
expect(prisma.userAchievement.findMany).toHaveBeenCalledWith({

Check warning on line 188 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L188

Added line #L188 was not covered by tests
where: { userId: "user-1", displayedAt: null },
orderBy: { unlockedAt: "asc" },
});
});

it("returns an empty list (not an error) when GAMIFICATION_ENABLED is off", async () => {
const { service, prisma } = makeService({ GAMIFICATION_ENABLED: "false" });

Check warning on line 195 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L194-L195

Added lines #L194 - L195 were not covered by tests

await expect(service.pending("user-1")).resolves.toEqual([]);
expect(prisma.userAchievement.findMany).not.toHaveBeenCalled();

Check warning on line 198 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L197-L198

Added lines #L197 - L198 were not covered by tests
});
});

describe("AchievementService.markDisplayed", () => {
it("404s on an achievement belonging to another user", async () => {
const { service, prisma } = makeService();
(prisma.userAchievement.findUnique as Mock).mockResolvedValue({

Check warning on line 205 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L202-L205

Added lines #L202 - L205 were not covered by tests
userId: "someone-else",
displayedAt: null,
});

await expect(

Check warning on line 210 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L210

Added line #L210 was not covered by tests
service.markDisplayed("user-1", "achievement-1"),
).rejects.toThrow();
expect(prisma.userAchievement.update).not.toHaveBeenCalled();

Check warning on line 213 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L213

Added line #L213 was not covered by tests
});

it("404s on an unknown id", async () => {
const { service, prisma } = makeService();
(prisma.userAchievement.findUnique as Mock).mockResolvedValue(null);

Check warning on line 218 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L216-L218

Added lines #L216 - L218 were not covered by tests

await expect(service.markDisplayed("user-1", "missing")).rejects.toThrow();

Check warning on line 220 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L220

Added line #L220 was not covered by tests
});

it("sets displayedAt on first call", async () => {
const { service, prisma } = makeService();
(prisma.userAchievement.findUnique as Mock).mockResolvedValue({

Check warning on line 225 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L223-L225

Added lines #L223 - L225 were not covered by tests
userId: "user-1",
displayedAt: null,
});

await service.markDisplayed("user-1", "achievement-1");

Check warning on line 230 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L230

Added line #L230 was not covered by tests

expect(prisma.userAchievement.update).toHaveBeenCalledWith({

Check warning on line 232 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L232

Added line #L232 was not covered by tests
where: { id: "achievement-1" },
data: { displayedAt: expect.any(Date) },
});
});

it("is idempotent: a second call is a no-op, not an error", async () => {
const { service, prisma } = makeService();
(prisma.userAchievement.findUnique as Mock).mockResolvedValue({

Check warning on line 240 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L238-L240

Added lines #L238 - L240 were not covered by tests
userId: "user-1",
displayedAt: new Date("2026-01-01T00:00:00.000Z"),
});

await expect(

Check warning on line 245 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L245

Added line #L245 was not covered by tests
service.markDisplayed("user-1", "achievement-1"),
).resolves.toBeUndefined();
expect(prisma.userAchievement.update).not.toHaveBeenCalled();

Check warning on line 248 in apps/api/src/gamification/achievements/achievement.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/gamification/achievements/achievement.service.spec.ts#L248

Added line #L248 was not covered by tests
});
});
Loading
Loading