mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'fix/v1437-jobs' into release/v1.43.7
This commit is contained in:
commit
ea933b91cc
59 files changed
+2340
-855
No files matched your search
@@ -358,6 +358,86 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect(byType.PULSE).toBe(false);
|
||||
});
|
||||
|
||||
// #1247 — weight entered here goes to Apple Health and never arrives back,
|
||||
// so a person who keeps their weight here watched the type go quiet.
|
||||
describe("a type the app writes back to the phone", () => {
|
||||
function mockWriteBack(enteredHereAt: Date | null): void {
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
]);
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation((async (
|
||||
strings: TemplateStringsArray,
|
||||
) => {
|
||||
const sql = strings.join("?");
|
||||
if (sql.includes("medication_intake_events")) {
|
||||
return [{ last_seen: null }];
|
||||
}
|
||||
if (sql.includes("<> 'APPLE_HEALTH'")) {
|
||||
return [{ type: "WEIGHT", last_seen: enteredHereAt }];
|
||||
}
|
||||
return [
|
||||
{ type: "WEIGHT", last_seen: ago(30 * DAY) },
|
||||
{ type: "PULSE", last_seen: ago(30 * DAY) },
|
||||
];
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function byType(body: Body) {
|
||||
return Object.fromEntries(
|
||||
body.metricFreshness.map((entry) => [entry.type, entry]),
|
||||
) as Record<
|
||||
string,
|
||||
Body["metricFreshness"][number] & {
|
||||
lastEnteredHereAt?: string | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
it("is not quiet while readings are entered here", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
const enteredHereAt = ago(2 * DAY);
|
||||
mockWriteBack(enteredHereAt);
|
||||
const entries = byType(await read());
|
||||
expect(entries.WEIGHT.stale).toBe(false);
|
||||
expect(entries.WEIGHT.lastEnteredHereAt).toBe(
|
||||
enteredHereAt.toISOString(),
|
||||
);
|
||||
// A read-only type keeps its own verdict and carries no such field.
|
||||
expect(entries.PULSE.stale).toBe(true);
|
||||
expect(entries.PULSE).not.toHaveProperty("lastEnteredHereAt");
|
||||
});
|
||||
|
||||
it("is quiet when nothing arrived and nothing was entered here", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
mockWriteBack(null);
|
||||
const entries = byType(await read());
|
||||
expect(entries.WEIGHT.stale).toBe(true);
|
||||
expect(entries.WEIGHT.lastEnteredHereAt).toBeNull();
|
||||
});
|
||||
|
||||
it("counts only what the entry still writes back", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: {
|
||||
entries: [
|
||||
{ id: "bodyMass", kind: "bodyMass", direction: "readOnly" },
|
||||
],
|
||||
},
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
mockWriteBack(ago(2 * DAY));
|
||||
const entries = byType(await read());
|
||||
expect(entries.WEIGHT.stale).toBe(true);
|
||||
expect(entries.WEIGHT).not.toHaveProperty("lastEnteredHereAt");
|
||||
});
|
||||
});
|
||||
|
||||
// #586 — the reporter's question is whether the phone delivers on its own or
|
||||
// only while the app is open. `lastSyncedAt` reads identically either way, so
|
||||
// it cannot answer it; these two fields are what can.
|
||||
|
||||
@@ -23,11 +23,13 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
|
||||
import {
|
||||
getHealthKitTypeArrivals,
|
||||
healthKitWriteBackTypes,
|
||||
newestAppleHealthDataAt,
|
||||
withHealthKitArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import {
|
||||
getAppleHealthDoseFreshness,
|
||||
getEnteredHereAt,
|
||||
getSourceFreshness,
|
||||
} from "@/lib/integrations/metric-freshness";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
@@ -183,14 +185,27 @@ export const GET = apiHandler(async () => {
|
||||
// v1.42.4 — Apple Health doses join the list as `MEDICATION_DOSE`, so the
|
||||
// phone can read when doses last arrived from the same entries as every
|
||||
// other type. Fail-soft on its own: a failed dose read drops that one entry.
|
||||
const [measurementSamples, doseSample, arrivals, syncProgress, health] =
|
||||
await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getAppleHealthDoseFreshness(user.id).catch(() => null),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
|
||||
]);
|
||||
// #1247 — the types the app writes back to the phone: what is entered here
|
||||
// goes to Apple Health and never arrives back, so each of them also carries
|
||||
// the newest reading entered here, and is quiet only when both are. Fail-
|
||||
// soft to undefined, which leaves the list as it was before.
|
||||
const [
|
||||
measurementSamples,
|
||||
doseSample,
|
||||
arrivals,
|
||||
syncProgress,
|
||||
health,
|
||||
enteredHere,
|
||||
] = await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getAppleHealthDoseFreshness(user.id).catch(() => null),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
|
||||
getEnteredHereAt(user.id, healthKitWriteBackTypes(entries)).catch(
|
||||
() => undefined,
|
||||
),
|
||||
]);
|
||||
const samples = doseSample
|
||||
? [...measurementSamples, doseSample].sort((a, b) =>
|
||||
a.type.localeCompare(b.type),
|
||||
@@ -216,7 +231,12 @@ export const GET = apiHandler(async () => {
|
||||
lastBackgroundSyncAt,
|
||||
syncHealth,
|
||||
metricFreshness: withHealthKitArrivals(
|
||||
classifyMetricFreshness(samples, syncHealth.verdict),
|
||||
classifyMetricFreshness(
|
||||
samples,
|
||||
syncHealth.verdict,
|
||||
Date.now(),
|
||||
enteredHere,
|
||||
),
|
||||
arrivals,
|
||||
),
|
||||
syncProgress,
|
||||
|
||||
@@ -210,6 +210,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
else process.env.TELEGRAM_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
@@ -396,7 +397,11 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
expect(ackText).toMatch(/1 hour/i);
|
||||
});
|
||||
|
||||
it("'skip:<medId>' creates an intake event with skipped=true and snoozes through end-of-day", async () => {
|
||||
it("'skip:<medId>' creates an intake event with skipped=true and snoozes through the person's own end of day", async () => {
|
||||
// 23:30 UTC is already 01:30 the next day in Berlin: the snooze ends at
|
||||
// Berlin's next midnight, not at the end of the UTC day half an hour on.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-10T23:30:00.000Z"));
|
||||
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -417,8 +422,7 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
const snoozedUntil = (updateArgs.data as { snoozedUntil: Date })
|
||||
.snoozedUntil;
|
||||
expect(snoozedUntil.getHours()).toBe(23);
|
||||
expect(snoozedUntil.getMinutes()).toBe(59);
|
||||
expect(snoozedUntil.toISOString()).toBe("2026-06-11T21:59:59.999Z");
|
||||
// Skip row + rest-of-day snooze commit atomically.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// v1.16.10 — an explicit skip refunds a previously-consumed stamp
|
||||
|
||||
Reference in new issue
Block a user