mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge remote-tracking branch 'origin/fix/delegated-fix2' into work/sharing-write-integration
This commit is contained in:
commit
b817a2d47e
25 files changed
+956
-204
No files matched your search
@@ -1,11 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
CoachLaunchProvider,
|
||||
resolveLaunchState,
|
||||
useCoachLaunch,
|
||||
} from "../coach-launch-context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
|
||||
@@ -19,8 +15,48 @@ import {
|
||||
* 3. The hook returns `null` when called outside the provider —
|
||||
* consumers degrade gracefully (e.g. the launch button renders
|
||||
* nothing rather than crashing).
|
||||
* 4. v1.36.x — the provider publishes nothing inside somebody else's
|
||||
* record, so (3) also covers every consumer under a switch. The shell
|
||||
* mounts no drawer there, and a launch call that opened nothing was a
|
||||
* button that silently did nothing on every page that offers one.
|
||||
*
|
||||
* Mutation check, run: dropping the `inSharedRecord` arm from the provider's
|
||||
* value memo → "publishes nothing inside somebody else's record" goes red
|
||||
* with the full context shape in the diff.
|
||||
*/
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
access: "write" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [OWNER], active: null, canSwitch: true },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { CoachLaunchProvider, resolveLaunchState, useCoachLaunch } =
|
||||
await import("../coach-launch-context");
|
||||
|
||||
function Probe({ output }: { output: string[] }) {
|
||||
const launch = useCoachLaunch();
|
||||
if (!launch) {
|
||||
@@ -72,6 +108,41 @@ describe("CoachLaunchProvider", () => {
|
||||
expect(html).toContain('data-slot="child-mount"');
|
||||
expect(html).toContain("child");
|
||||
});
|
||||
|
||||
it("publishes nothing inside somebody else's record, at both levels", () => {
|
||||
for (const access of ["read", "write"] as const) {
|
||||
mockAccessRef.value = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access, canWrite: access === "write" },
|
||||
canSwitch: true,
|
||||
};
|
||||
const output: string[] = [];
|
||||
renderToStaticMarkup(
|
||||
<CoachLaunchProvider>
|
||||
<Probe output={output} />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// The whole recorded value, not a boolean: a failure prints the shape
|
||||
// that leaked rather than `false !== true`.
|
||||
expect(output, `grant level: ${access}`).toEqual(["null"]);
|
||||
}
|
||||
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
|
||||
});
|
||||
|
||||
it("still renders its children there — only the launch value is withheld", () => {
|
||||
mockAccessRef.value = {
|
||||
accounts: [OWNER],
|
||||
active: OWNER,
|
||||
canSwitch: true,
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<CoachLaunchProvider>
|
||||
<div data-slot="child-mount">child</div>
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
|
||||
expect(html).toContain('data-slot="child-mount"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
@@ -191,7 +192,30 @@ export interface CoachLaunchProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the drawer's launch state — and publishes it only where a drawer
|
||||
* exists to receive it.
|
||||
*
|
||||
* v1.36.x — the shell stopped mounting `<LayoutCoachMount>` inside somebody
|
||||
* else's record, and this provider kept answering: `askCoach()` set an open
|
||||
* flag that nothing was reading, so every per-page Coach entry point became a
|
||||
* button that did nothing at all. A control that errors tells a person where
|
||||
* they stand; one that silently does nothing tells them the product is
|
||||
* broken.
|
||||
*
|
||||
* The gate lives here, on the publisher, rather than in `useCoachLaunch()`.
|
||||
* Six components read this context and five already treat `null` as "no Coach
|
||||
* here", so withholding the value fixes all of them at once and a seventh
|
||||
* inherits the rule. Putting it in the hook instead would drag the account
|
||||
* query into every one of those components for an answer that is the same in
|
||||
* all of them.
|
||||
*
|
||||
* Nothing is taken away by this: `/insights` and `/coach` are not
|
||||
* shared-record destinations, so the Coach is outside what sharing covers to
|
||||
* begin with.
|
||||
*/
|
||||
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [closeIntent, setCloseIntent] = useState<CoachCloseIntent | null>(null);
|
||||
const [prefill, setPrefill] = useState<string | null>(null);
|
||||
@@ -277,20 +301,24 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<CoachLaunchValue>(
|
||||
() => ({
|
||||
open,
|
||||
closeIntent,
|
||||
prefill,
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
workoutId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
setOpen: handleSetOpen,
|
||||
}),
|
||||
const value = useMemo<CoachLaunchValue | null>(
|
||||
() =>
|
||||
inSharedRecord
|
||||
? null
|
||||
: {
|
||||
open,
|
||||
closeIntent,
|
||||
prefill,
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
workoutId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
setOpen: handleSetOpen,
|
||||
},
|
||||
[
|
||||
inSharedRecord,
|
||||
closeIntent,
|
||||
open,
|
||||
prefill,
|
||||
@@ -316,6 +344,12 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
* `<CoachLaunchProvider>` so consumer components can degrade gracefully
|
||||
* (e.g. the hero strip's "Ask the coach" action stays disabled until
|
||||
* the provider mounts).
|
||||
*
|
||||
* v1.36.x — also `null` inside somebody else's record, and by the same
|
||||
* mechanism: the provider publishes nothing there (see its own docblock), so
|
||||
* every consumer's existing `if (!launch) return null` becomes right without
|
||||
* being told. Kept as a plain context read on purpose — the answer is decided
|
||||
* once, in the provider, rather than by a hook that six components call.
|
||||
*/
|
||||
export function useCoachLaunch(): CoachLaunchValue | null {
|
||||
return useContext(CoachLaunchContext);
|
||||
|
||||
Reference in new issue
Block a user