mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(oura): keep the scope note when a run never proved the token
The refresh-abort paths (no client credentials, refresh token lost to a peer) and a run where no collection succeeded wrote an empty scope list, clearing the reconnect hint until the next good run. Such a run learned nothing about the grant, so the stored note now stands.
This commit is contained in:
1 parent
55ddad3d6a
commit
530d1a6be2
2 files changed
+52
-14
No files matched your search
@@ -1017,9 +1017,40 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
|
||||
// Nothing proved the token, so nothing was learned about the grant.
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the scope note when the refresh is abandoned for want of credentials", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
// A new 401 the note does not explain sends the run to the refresh.
|
||||
fetchSleepMock.mockRejectedValue(err401());
|
||||
denyScopedCollections();
|
||||
getCredsMock.mockResolvedValue(null);
|
||||
|
||||
await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the scope note when the refresh loses its token to a peer", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
fetchSleepMock.mockRejectedValue(err401());
|
||||
denyScopedCollections();
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
storeTokensMock.mockResolvedValue(null);
|
||||
|
||||
await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+20
-13
@@ -246,11 +246,13 @@ export interface OuraFetchResult {
|
||||
*/
|
||||
sleepSweeps: SleepSegmentSweep[];
|
||||
/**
|
||||
* Collections refused with 401/403 while a sibling succeeded with the same
|
||||
* Collections refused with 401 while a sibling succeeded with the same
|
||||
* token: the token is fine, the grant does not cover them. Not failures; the
|
||||
* card asks for one reconnect instead.
|
||||
* card asks for one reconnect instead. `null` when the run never proved the
|
||||
* token (no collection succeeded, or the refresh was abandoned): it learned
|
||||
* nothing about the grant, so the note from the last run that did stands.
|
||||
*/
|
||||
scopeDenied: string[];
|
||||
scopeDenied: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,8 +288,8 @@ function isScopeRefusal(f: OuraCollectionFailure): boolean {
|
||||
function splitScopeDenied(
|
||||
failures: OuraCollectionFailure[],
|
||||
tokenProven: boolean,
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
|
||||
if (!tokenProven) return { failures, scopeDenied: [] };
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } {
|
||||
if (!tokenProven) return { failures, scopeDenied: null };
|
||||
return {
|
||||
failures: failures.filter((f) => !isScopeRefusal(f)),
|
||||
scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name),
|
||||
@@ -490,7 +492,7 @@ async function fetchAll(
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
scopeDenied: null,
|
||||
};
|
||||
|
||||
const rotated = await refreshAccessToken(refreshToken, creds);
|
||||
@@ -508,7 +510,7 @@ async function fetchAll(
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
scopeDenied: null,
|
||||
};
|
||||
|
||||
const retry = await attempt(usableToken, first.failed);
|
||||
@@ -565,15 +567,20 @@ export async function syncUserOura(
|
||||
// Remember which collections the grant does not cover, so the settings card
|
||||
// can ask for one reconnect. Best-effort: the note is a convenience, the
|
||||
// sync outcome does not depend on it.
|
||||
if (result.scopeDenied.length > 0) {
|
||||
// A run that never proved the token says nothing about the grant and leaves
|
||||
// the note as it was.
|
||||
const scopeDenied = result.scopeDenied;
|
||||
if (scopeDenied && scopeDenied.length > 0) {
|
||||
annotate({
|
||||
meta: { oura_scope_denied: result.scopeDenied },
|
||||
meta: { oura_scope_denied: scopeDenied },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await syncOuraScopeDenied(userId, result.scopeDenied);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
|
||||
if (scopeDenied) {
|
||||
try {
|
||||
await syncOuraScopeDenied(userId, scopeDenied);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear whatever an earlier scoring left under the re-fetched sleep records
|
||||
|
||||
Reference in new issue
Block a user