mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Let somebody actually set the date a shared access lapses on
The invite route has accepted expiresAt, stored it and annotated on it since the grant model shipped. The card sent a hardcoded null, so the column existed, the validator accepted it, the wide event reported on it, and no person could set one. The release note said an invitation could carry a lapse date. The form now offers the date, floored at tomorrow because a grant that lapses today confers nothing. A chosen day maps to the END of that day in the browser's own zone, so "until the 30th" means the 30th rather than the midnight that starts it, which is the reading anybody picking a date has in mind. Empty stays the default and still means no lapse date. Both ends are pinned rather than one: the control is asserted in the rendered form, and the mapping is exported so its contract holds without a click, which is this repository's convention for component tests.
This commit is contained in:
1 parent
8a5293bc1a
commit
6780e54036
8 files changed
+119
-1
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
|
||||
return {
|
||||
...actual,
|
||||
useInviteGrant: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
};
|
||||
});
|
||||
|
||||
import { GrantInviteCard, endOfDayIso } from "../grant-invite-card";
|
||||
|
||||
/**
|
||||
* The lapse date, at both ends.
|
||||
*
|
||||
* `expiresAt` was accepted by the invite route, stored, and annotated on from
|
||||
* the day the grant model shipped, while the card sent a hardcoded `null`. A
|
||||
* release note said an invitation could carry a lapse date and no one could
|
||||
* set one. That is the two-ended failure this repository keeps rediscovering,
|
||||
* so both ends are pinned here: the control exists in the rendered form, and
|
||||
* the value it produces is the END of the chosen day rather than the midnight
|
||||
* that starts it.
|
||||
*
|
||||
* Project convention is SSR-only, no `@testing-library/react`; the mapping is
|
||||
* exported so its contract can be pinned without a click, and the submit path
|
||||
* is covered by the sharing browser spec.
|
||||
*/
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("grant invitation lapse date", () => {
|
||||
it("offers a date control on the invitation form", () => {
|
||||
const html = render(<GrantInviteCard />);
|
||||
expect(html).toContain('data-slot="grant-invite-expires"');
|
||||
expect(html).toContain('type="date"');
|
||||
});
|
||||
|
||||
it("maps a chosen day to the end of that day, not its midnight", () => {
|
||||
const iso = endOfDayIso("2026-09-30");
|
||||
expect(iso).not.toBeNull();
|
||||
const end = new Date(iso as string);
|
||||
// Read back in the same zone the mapping used, so the assertion holds
|
||||
// wherever the suite runs rather than only under the pinned UTC clock.
|
||||
expect(end.getFullYear()).toBe(2026);
|
||||
expect(end.getMonth()).toBe(8);
|
||||
expect(end.getDate()).toBe(30);
|
||||
expect(end.getHours()).toBe(23);
|
||||
expect(end.getMinutes()).toBe(59);
|
||||
});
|
||||
|
||||
it("treats an empty field as no lapse date", () => {
|
||||
expect(endOfDayIso("")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a value that is not a date rather than sending an invalid instant", () => {
|
||||
expect(endOfDayIso("not-a-date")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export function GrantInviteCard() {
|
||||
const { t } = useTranslations();
|
||||
const invite = useInviteGrant();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [expiresOn, setExpiresOn] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [invited, setInvited] = useState<string | null>(null);
|
||||
|
||||
@@ -44,10 +45,11 @@ export function GrantInviteCard() {
|
||||
setError(null);
|
||||
setInvited(null);
|
||||
invite.mutate(
|
||||
{ identifier: value, expiresAt: null },
|
||||
{ identifier: value, expiresAt: endOfDayIso(expiresOn) },
|
||||
{
|
||||
onSuccess: (grant) => {
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setInvited(grant.account.username);
|
||||
},
|
||||
onError: (err) => setError(t(inviteErrorKey(err))),
|
||||
@@ -80,6 +82,22 @@ export function GrantInviteCard() {
|
||||
{t("recordSharing.invite.identifierHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-invite-expires">
|
||||
{t("recordSharing.invite.expiresLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="grant-invite-expires"
|
||||
data-slot="grant-invite-expires"
|
||||
type="date"
|
||||
value={expiresOn}
|
||||
min={tomorrowIso()}
|
||||
onChange={(e) => setExpiresOn(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.invite.expiresHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="grant-invite-submit"
|
||||
@@ -104,6 +122,29 @@ export function GrantInviteCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen day, as the instant access stops.
|
||||
*
|
||||
* A date input yields a bare `YYYY-MM-DD`, and the grant's `expiresAt` is
|
||||
* checked live against `now` on every request. Anchoring at the END of the
|
||||
* chosen day in the browser's own zone is what makes "until the 30th" mean the
|
||||
* 30th rather than the midnight that starts it, which is the reading anybody
|
||||
* picking a date has in mind. Empty means no lapse date, which is the default
|
||||
* and stays the common case.
|
||||
*/
|
||||
export function endOfDayIso(day: string): string | null {
|
||||
if (day.length === 0) return null;
|
||||
const end = new Date(`${day}T23:59:59.999`);
|
||||
return Number.isNaN(end.getTime()) ? null : end.toISOString();
|
||||
}
|
||||
|
||||
/** The earliest day worth offering: a grant that lapses today confers nothing. */
|
||||
function tomorrowIso(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused invitation.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user