feat(frontend): add intake journal and shared note flows (#648)

* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
This commit is contained in:
Daniel Volz
2026-05-24 14:00:30 +02:00
committed by GitHub
parent e4a1b449c6
commit c78fc43083
67 changed files with 5414 additions and 580 deletions
+224 -16
View File
@@ -3,16 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useShare } from "../../hooks/useShare";
import type { Medication } from "../../types";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("useShare", () => {
let mockAlert: ReturnType<typeof vi.fn>;
let mockClipboard: { writeText: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockAlert = vi.fn();
global.alert = mockAlert as unknown as typeof global.alert;
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
Object.defineProperty(navigator, "clipboard", {
@@ -48,10 +62,13 @@ describe("useShare", () => {
expect(result.current.sharePeople).toEqual([]);
expect(result.current.shareSelectedPerson).toBe("");
expect(result.current.shareSelectedDays).toBe(30);
expect(result.current.shareSelectedExpiryDays).toBeNull();
expect(result.current.shareAllowJournalNotes).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.activeShareLinks).toEqual([]);
});
it("opens share dialog with people from medications", () => {
it("opens share dialog with people from medications", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
@@ -85,6 +102,10 @@ describe("useShare", () => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.showShareDialog).toBe(true);
expect(result.current.sharePeople).toEqual(["all", "Alice", "Bob", "Charlie"]);
expect(result.current.shareSelectedPerson).toBe("Alice");
@@ -146,24 +167,26 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30 }),
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: null, allowJournalNotes: false }),
})
);
expect(authFetchMock).toHaveBeenNthCalledWith(1, "/api/share");
expect(result.current.shareLink).toBe("http://localhost:5173/share/test-token");
});
it("handles share link generation error", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to generate" }),
});
authFetchMock
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareLinks: [] }) } as Response)
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to generate" }) } as Response);
const { result } = renderHook(() => useShare());
@@ -187,15 +210,18 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "Failed to generate", tone: "error" });
expect(result.current.shareLink).toBeNull();
});
it("handles network error on share link generation", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
authFetchMock
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareLinks: [] }) } as Response)
.mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useShare());
@@ -219,10 +245,11 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "share.generateFailed", tone: "error" });
});
it("does nothing when generateShareLink called without selected person", async () => {
@@ -233,7 +260,7 @@ describe("useShare", () => {
await result.current.generateShareLink();
});
expect(fetch).not.toHaveBeenCalled();
expect(authFetchMock).not.toHaveBeenCalled();
});
it("copies share link to clipboard", async () => {
@@ -338,17 +365,198 @@ describe("useShare", () => {
expect(result.current.showShareDialog).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.shareCopied).toBe(false);
expect(result.current.shareSelectedExpiryDays).toBeNull();
expect(result.current.shareAllowJournalNotes).toBe(false);
expect(result.current.activeShareLinks).toEqual([]);
});
it("allows changing selected person and days", () => {
it("includes selected expiry when generating a share link", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareSelectedExpiryDays(7);
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(authFetchMock).toHaveBeenCalledWith(
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: 7, allowJournalNotes: false }),
})
);
});
it("includes the shared journal-note permission when generating a share link", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareAllowJournalNotes(true);
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(authFetchMock).toHaveBeenCalledWith(
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: null, allowJournalNotes: true }),
})
);
});
it("loads active share links when opening the dialog", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
shareLinks: [
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
],
}),
})
.mockResolvedValue({ ok: true, json: () => Promise.resolve({ token: "test-token" }) });
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.activeShareLinks).toHaveLength(1);
expect(result.current.activeShareLinks[0].token).toBe("abcdef0123456789");
});
it("revokes an active share link", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
shareLinks: [
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: false,
shareUrl: "/share/abcdef0123456789",
},
],
}),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await result.current.revokeShareLink("abcdef0123456789");
});
expect(authFetchMock).toHaveBeenCalledWith("/api/share/abcdef0123456789", { method: "DELETE" });
expect(result.current.activeShareLinks).toEqual([]);
});
it("allows changing selected person, days, and expiry", () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.setShareSelectedPerson("Bob");
result.current.setShareSelectedDays(90);
result.current.setShareSelectedExpiryDays(30);
result.current.setShareAllowJournalNotes(true);
});
expect(result.current.shareSelectedPerson).toBe("Bob");
expect(result.current.shareSelectedDays).toBe(90);
expect(result.current.shareSelectedExpiryDays).toBe(30);
expect(result.current.shareAllowJournalNotes).toBe(true);
});
});