mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(workouts): save an edited resubmit of a hand-entered workout
The manual form sends one manual: id per opening and resends it after an edit. The batch route now treats a MANUAL entry with a manual: id as an overwrite, the way stats: measurements are: the stored row takes the new values and the entry reports "updated", or "duplicate" when nothing changed. Records keyed on the workout are removed under the personal-records lock and re-derived. Every other id stays first-write-wins. The status enum gains "updated".
This commit is contained in:
1 parent
6bb65824bf
commit
916f918674
6 files changed
+271
-19
No files matched your search
+12
-8
@@ -4706,13 +4706,14 @@ paths:
|
||||
Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100
|
||||
workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1
|
||||
`WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h).
|
||||
Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately.
|
||||
An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array
|
||||
and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched`
|
||||
(repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is
|
||||
refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body
|
||||
ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with
|
||||
`workout.batch.payload_too_large`.
|
||||
Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint
|
||||
accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered
|
||||
one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports
|
||||
`updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is
|
||||
attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry
|
||||
whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the
|
||||
batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling
|
||||
receive a 413 with `workout.batch.payload_too_large`.
|
||||
|
||||
|
||||
**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the
|
||||
@@ -43190,11 +43191,14 @@ components:
|
||||
- inserted
|
||||
- duplicate
|
||||
- enriched
|
||||
- updated
|
||||
- skipped
|
||||
description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written.
|
||||
`enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it;
|
||||
the workout row itself was not written, and the entry also counts towards the top-level `duplicates`.
|
||||
`skipped`: the server refused the entry, see `reason`."
|
||||
`updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was
|
||||
sent again with different values and the stored row now carries them; no row was inserted, so it also counts
|
||||
towards `duplicates`. `skipped`: the server refused the entry, see `reason`."
|
||||
reason:
|
||||
description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client
|
||||
restart, `invalid_samples` for a heart-rate series that failed validation."
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { lockPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout";
|
||||
@@ -129,8 +130,63 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
* to a workout that had none, which is what lets a history sweep count
|
||||
* its own progress. `skipped` covers entries the server refused: an
|
||||
* unusable external id, or a `samples` array that failed validation.
|
||||
* `updated` says a hand-entered workout (`manual:` id, source MANUAL) was
|
||||
* sent again with different values and the stored row now carries them.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped";
|
||||
type EntryStatus =
|
||||
"inserted" | "duplicate" | "enriched" | "updated" | "skipped";
|
||||
|
||||
/** The workout columns a hand-entered workout's resubmit may overwrite. */
|
||||
const MANUAL_OVERWRITE_FIELDS = [
|
||||
"sportType",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"durationSec",
|
||||
"totalEnergyKcal",
|
||||
"totalDistanceM",
|
||||
"avgHeartRate",
|
||||
"maxHeartRate",
|
||||
"minHeartRate",
|
||||
"stepCount",
|
||||
"elevationM",
|
||||
"pauseDurationSec",
|
||||
] as const;
|
||||
type ManualOverwriteField = (typeof MANUAL_OVERWRITE_FIELDS)[number];
|
||||
type ManualOverwriteValues = {
|
||||
[K in ManualOverwriteField]: string | number | Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workout entered by hand carries one id per form opening, and the form
|
||||
* resends it with the edited values when the person corrects a submit. Such a
|
||||
* resubmit is an edit of the stored row, the way a `stats:` measurement
|
||||
* re-post is. Every other id stays first-write-wins.
|
||||
*/
|
||||
function isManualOverwrite(source: unknown, externalId: string | null) {
|
||||
return (
|
||||
source === "MANUAL" &&
|
||||
externalId !== null &&
|
||||
externalId.startsWith("manual:")
|
||||
);
|
||||
}
|
||||
|
||||
/** The changed columns of a manual resubmit, or null when nothing differs. */
|
||||
function manualOverwriteChanges(
|
||||
stored: ManualOverwriteValues,
|
||||
sent: Prisma.WorkoutCreateManyInput,
|
||||
): Partial<ManualOverwriteValues> | null {
|
||||
const changes: Partial<ManualOverwriteValues> = {};
|
||||
for (const field of MANUAL_OVERWRITE_FIELDS) {
|
||||
const next = (sent[field] ?? null) as string | number | Date | null;
|
||||
const prev = stored[field];
|
||||
const same =
|
||||
next instanceof Date && prev instanceof Date
|
||||
? next.getTime() === prev.getTime()
|
||||
: next === prev;
|
||||
if (!same) changes[field] = next;
|
||||
}
|
||||
return Object.keys(changes).length > 0 ? changes : null;
|
||||
}
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -483,6 +539,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
let duplicateCount = droppedByWriteDedup.length;
|
||||
let insertedCount = 0;
|
||||
let enrichedCount = 0;
|
||||
let updatedCount = 0;
|
||||
const insertedIdByIndex = new Map<number, string>();
|
||||
|
||||
if (survivors.length > 0) {
|
||||
@@ -510,16 +567,45 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
source: true,
|
||||
externalId: true,
|
||||
samples: { select: { workoutId: true } },
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
durationSec: true,
|
||||
totalEnergyKcal: true,
|
||||
totalDistanceM: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
minHeartRate: true,
|
||||
stepCount: true,
|
||||
elevationM: true,
|
||||
pauseDurationSec: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const existingByKey = new Map<string, { id: string; hasSeries: boolean }>();
|
||||
const existingByKey = new Map<
|
||||
string,
|
||||
{ id: string; hasSeries: boolean; values: ManualOverwriteValues }
|
||||
>();
|
||||
for (const row of existing) {
|
||||
if (row.externalId === null) continue;
|
||||
existingByKey.set(`${row.source}::${row.externalId}`, {
|
||||
id: row.id,
|
||||
hasSeries: row.samples !== null,
|
||||
values: {
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
totalEnergyKcal: row.totalEnergyKcal,
|
||||
totalDistanceM: row.totalDistanceM,
|
||||
avgHeartRate: row.avgHeartRate,
|
||||
maxHeartRate: row.maxHeartRate,
|
||||
minHeartRate: row.minHeartRate,
|
||||
stepCount: row.stepCount,
|
||||
elevationM: row.elevationM,
|
||||
pauseDurationSec: row.pauseDurationSec,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -530,6 +616,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
workoutId: string;
|
||||
samples: NonNullable<Prepared["samples"]>;
|
||||
}> = [];
|
||||
/** Hand-entered workouts this batch edits in place. */
|
||||
const toOverwrite: Array<{
|
||||
index: number;
|
||||
workoutId: string;
|
||||
externalId: string;
|
||||
changes: Partial<ManualOverwriteValues>;
|
||||
}> = [];
|
||||
/** Workouts already claimed by an earlier entry of THIS batch. */
|
||||
const enrichClaimed = new Set<string>();
|
||||
for (const p of survivors) {
|
||||
@@ -537,7 +630,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
|
||||
: null;
|
||||
const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined;
|
||||
if (known) {
|
||||
if (
|
||||
known &&
|
||||
p.dedupKey &&
|
||||
isManualOverwrite(p.dedupKey.source, p.dedupKey.externalId)
|
||||
) {
|
||||
const changes = enrichClaimed.has(known.id)
|
||||
? null
|
||||
: manualOverwriteChanges(known.values, p.row);
|
||||
if (changes) {
|
||||
enrichClaimed.add(known.id);
|
||||
toOverwrite.push({
|
||||
index: p.index,
|
||||
workoutId: known.id,
|
||||
externalId: p.dedupKey.externalId,
|
||||
changes,
|
||||
});
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
} else if (known) {
|
||||
if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) {
|
||||
enrichClaimed.add(known.id);
|
||||
// Enrichment. The status is assigned after the write, because
|
||||
@@ -560,12 +673,45 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0 || toEnrich.length > 0) {
|
||||
if (toInsert.length > 0 || toEnrich.length > 0 || toOverwrite.length > 0) {
|
||||
const CHUNK = 100;
|
||||
/** Workout ids whose series row this request actually wrote. */
|
||||
const seriesLanded = new Set<string>();
|
||||
/** Batch indices whose manual edit this request actually wrote. */
|
||||
const overwritten = new Set<number>();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// A hand-entered workout sent again with other values. Columns are
|
||||
// copied one by one from the validated entry; the records the old
|
||||
// values set go with them and the detection pass below re-derives
|
||||
// the honest best, as the DELETE does.
|
||||
if (toOverwrite.length > 0) {
|
||||
await lockPersonalRecordsForUser(tx, user.id);
|
||||
}
|
||||
for (const o of toOverwrite) {
|
||||
const data: Prisma.WorkoutUpdateManyMutationInput = {};
|
||||
for (const field of MANUAL_OVERWRITE_FIELDS) {
|
||||
if (field in o.changes) {
|
||||
(data as Record<string, unknown>)[field] = o.changes[field];
|
||||
}
|
||||
}
|
||||
const written = await tx.workout.updateMany({
|
||||
where: { id: o.workoutId, userId: user.id, source: "MANUAL" },
|
||||
data,
|
||||
});
|
||||
if (written.count === 1) {
|
||||
overwritten.add(o.index);
|
||||
await tx.personalRecord.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "MANUAL",
|
||||
metricSlot: { not: null },
|
||||
externalId: o.externalId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
|
||||
const withoutExternalId = toInsert.filter((p) => p.dedupKey === null);
|
||||
|
||||
@@ -686,6 +832,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
if (seriesLanded.has(e.workoutId)) enrichedCount += 1;
|
||||
duplicateCount += 1;
|
||||
}
|
||||
|
||||
// Like an enrichment, an edit inserts no row, so it counts towards
|
||||
// `duplicates`; the per-entry status says the stored row changed.
|
||||
for (const o of toOverwrite) {
|
||||
const landed = overwritten.has(o.index);
|
||||
results[o.index] = {
|
||||
index: o.index,
|
||||
status: landed ? "updated" : "duplicate",
|
||||
};
|
||||
if (landed) updatedCount += 1;
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,6 +859,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
updated: updatedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -739,6 +898,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
updated: updatedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -750,7 +910,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// An enrichment changes what the workout reads as — the detail seam
|
||||
// gains its curve, the list gains its glyph — so it busts the cache
|
||||
// too, even though no workout row was written.
|
||||
if (insertedCount > 0 || enrichedCount > 0) {
|
||||
if (insertedCount > 0 || enrichedCount > 0 || updatedCount > 0) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,18 @@ describe("saveManualWorkout — the wire", () => {
|
||||
).resolves.toBe("duplicate");
|
||||
});
|
||||
|
||||
it("treats an edited resubmit (`updated`) as saved and refreshes", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "updated" }] }),
|
||||
);
|
||||
const client = fakeClient();
|
||||
|
||||
await expect(
|
||||
saveManualWorkout(ENTRY, client as unknown as QueryClient),
|
||||
).resolves.toBe("updated");
|
||||
expect(client.invalidateQueries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the route skipped the entry, and refreshes nothing", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "skipped" }] }),
|
||||
|
||||
@@ -59,7 +59,7 @@ interface BatchResult {
|
||||
entries?: Array<{ status: string }>;
|
||||
}
|
||||
|
||||
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate";
|
||||
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate" | "updated";
|
||||
|
||||
/**
|
||||
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
|
||||
@@ -74,7 +74,7 @@ export async function saveManualWorkout(
|
||||
workouts: [entry],
|
||||
});
|
||||
const status = result?.entries?.[0]?.status;
|
||||
if (status !== "inserted" && status !== "duplicate") {
|
||||
if (status !== "inserted" && status !== "duplicate" && status !== "updated") {
|
||||
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
|
||||
}
|
||||
// The list, the dashboard tile and every detail read sit under this
|
||||
@@ -112,7 +112,8 @@ export function ManualWorkoutForm({
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Minted once per opened form and sent with every submit, so a second tap
|
||||
// or a retried request lands as a duplicate rather than a second row.
|
||||
// or a retried request lands as a duplicate rather than a second row, and
|
||||
// a submit after an edit updates the row the first submit stored.
|
||||
const [externalId] = useState(newManualWorkoutExternalId);
|
||||
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
|
||||
emptyManualWorkoutDraft(new Date(), timezone),
|
||||
|
||||
@@ -38,9 +38,9 @@ const workoutBatchEntryResult = z
|
||||
.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
status: z
|
||||
.enum(["inserted", "duplicate", "enriched", "skipped"])
|
||||
.enum(["inserted", "duplicate", "enriched", "updated", "skipped"])
|
||||
.describe(
|
||||
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `skipped`: the server refused the entry, see `reason`.",
|
||||
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`.",
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
@@ -349,7 +349,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
||||
description:
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
"**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* - the entry lands as `MANUAL`, with the start, duration and metres the
|
||||
* form computed, and the list serves it;
|
||||
* - a second submit of the same form is a `duplicate`, not a second row;
|
||||
* - a submit after an edit is `updated` and the one row carries the edit,
|
||||
* while a synced id with changed values stays first-write-wins;
|
||||
* - delete removes a hand-entered workout and refuses a synced one.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -163,6 +165,79 @@ describe("a workout entered by hand", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("updates the stored row when the same form is sent again with an edit", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = formEntry(newManualWorkoutExternalId());
|
||||
await post({ workouts: [entry] });
|
||||
const stored = await prisma.workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
// A record the first values set; the edit must not leave it behind.
|
||||
await prisma.personalRecord.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
metricType: "WALKING_RUNNING_DISTANCE",
|
||||
metricSlot: "longest_distance_run",
|
||||
direction: "MAX",
|
||||
value: 8046.7,
|
||||
unit: "m",
|
||||
achievedAt: stored.startedAt,
|
||||
source: "MANUAL",
|
||||
externalId: entry.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
const edited = { ...entry, totalDistanceM: 6000, totalEnergyKcal: 380 };
|
||||
const res = await post({ workouts: [edited] });
|
||||
|
||||
expect((await res.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "updated" },
|
||||
]);
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: stored.id,
|
||||
totalDistanceM: 6000,
|
||||
totalEnergyKcal: 380,
|
||||
durationSec: 45 * 60,
|
||||
});
|
||||
expect(
|
||||
await prisma.personalRecord.count({
|
||||
where: { userId: TEST_USER_ID, externalId: entry.externalId },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// The same edit once more changes nothing and says so.
|
||||
const again = await post({ workouts: [edited] });
|
||||
expect((await again.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "duplicate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a synced workout first-write-wins under the same resend", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = {
|
||||
...formEntry("hk-uuid-1"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
externalId: "8F14E45F-CEEA-467A-9575-0F5D6C5A1B2C",
|
||||
};
|
||||
await post({ workouts: [entry] });
|
||||
|
||||
const res = await post({
|
||||
workouts: [{ ...entry, totalDistanceM: 1234 }],
|
||||
});
|
||||
|
||||
expect((await res.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "duplicate" },
|
||||
]);
|
||||
const row = await prisma.workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(row.totalDistanceM).not.toBe(1234);
|
||||
});
|
||||
|
||||
it("can be deleted, and is gone from the list", async () => {
|
||||
await post({ workouts: [formEntry(newManualWorkoutExternalId())] });
|
||||
const stored = await getPrismaClient().workout.findFirstOrThrow({
|
||||
|
||||
Reference in new issue
Block a user