diff --git a/src/lib/integrations/__tests__/sources-in-use.test.ts b/src/lib/integrations/__tests__/sources-in-use.test.ts index 92f72bb06..9f1aa9fed 100644 --- a/src/lib/integrations/__tests__/sources-in-use.test.ts +++ b/src/lib/integrations/__tests__/sources-in-use.test.ts @@ -38,6 +38,21 @@ describe("getSourcesInUse", () => { expect(await getSourcesInUse("u1")).toEqual(["MANUAL"]); }); + it("does not count a source whose readings were all deleted", async () => { + // The probe answers like the database: a MANUAL row exists, but it is a + // tombstone, so only a probe that admits deleted rows finds it. + vi.mocked(prisma.measurement.findFirst).mockImplementation(((args: { + where: { source: string; deletedAt?: null }; + }) => + Promise.resolve( + args.where.source === "MANUAL" && !("deletedAt" in args.where) + ? { id: "m-deleted" } + : null, + )) as never); + + expect(await getSourcesInUse("u1")).toEqual([]); + }); + it("lists a connected integration that has not delivered yet", async () => { vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null); vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ diff --git a/src/lib/integrations/sources-in-use.ts b/src/lib/integrations/sources-in-use.ts index 3406b7d19..315065b37 100644 --- a/src/lib/integrations/sources-in-use.ts +++ b/src/lib/integrations/sources-in-use.ts @@ -17,7 +17,8 @@ export const RANKABLE_SOURCES: readonly MeasurementSource[] = Array.from( * one measurement from, or an integration it has connected that has not * delivered yet. * - * The Settings ladder lists only these. Ranking a source the person has never + * Deleted (tombstoned) readings do not count. The Settings ladder lists only + * these. Ranking a source the person has never * used decides nothing, and ten of them per metric buried the two that do. * The ladders themselves stay whole: hidden sources keep their slot, so a * source that starts delivering later lands where the ladder already put it. @@ -33,7 +34,12 @@ export async function getSourcesInUse( Promise.all( RANKABLE_SOURCES.map((source) => prisma.measurement - .findFirst({ where: { userId, source }, select: { id: true } }) + .findFirst({ + // A deleted reading is not data: a source whose readings were + // all deleted is no longer in use. + where: { userId, source, deletedAt: null }, + select: { id: true }, + }) .then((row) => (row ? source : null)), ), ), diff --git a/tests/integration/sources-in-use.test.ts b/tests/integration/sources-in-use.test.ts new file mode 100644 index 000000000..462f8c867 --- /dev/null +++ b/tests/integration/sources-in-use.test.ts @@ -0,0 +1,61 @@ +/** + * The sources the Settings ladder lists, against real Postgres. + * + * A source counts while the account holds a live reading from it. Deleting + * readings tombstones them (`deletedAt`), so the probe has to skip the + * tombstones or a source whose readings were all deleted stays marked as in + * use for good. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function seed() { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "sources-in-use", + email: "sources-in-use@example.test", + role: "USER", + }, + }); + const reading = (source: "MANUAL" | "APPLE_HEALTH", minutes: number) => + prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 80, + unit: "kg", + source, + measuredAt: new Date(Date.UTC(2026, 4, 12, 7, minutes)), + }, + }); + return { prisma, user, reading }; +} + +describe("getSourcesInUse", () => { + it("lists a source with a live reading", async () => { + const { user, reading } = await seed(); + await reading("MANUAL", 0); + const { getSourcesInUse } = + await import("@/lib/integrations/sources-in-use"); + expect(await getSourcesInUse(user.id)).toEqual(["MANUAL"]); + }); + + it("drops a source once every reading from it is deleted", async () => { + const { prisma, user, reading } = await seed(); + const manual = await reading("MANUAL", 0); + await reading("APPLE_HEALTH", 1); + await prisma.measurement.update({ + where: { id: manual.id }, + data: { deletedAt: new Date() }, + }); + const { getSourcesInUse } = + await import("@/lib/integrations/sources-in-use"); + expect(await getSourcesInUse(user.id)).toEqual(["APPLE_HEALTH"]); + }); +});