cab0fcbba7
* fix: make dismissed doses robust against schedule/timezone changes - Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs - Add POST /medications/dismiss-until endpoint to set dismissed date - Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date - Update frontend to use medication-level dismissedUntil for filtering - Remove old dismissMissedDoses function from useDoses hook (was using dose IDs) - Add backward-compatible ALTER TABLE migration for dismissed_until column - Add 5 integration tests for dismiss-until functionality - Update test schemas with new column The old approach stored individual dose IDs which broke when schedule or timezone settings changed (dose IDs contain timestamps). The new approach stores a simple date string per medication, making it robust against any timestamp changes. * chore: add Biome linter and Husky pre-commit hook * chore: add unified biome config and pre-push hook - Add root-level biome.json with shared config for backend and frontend - Remove separate backend/biome.json and frontend/biome.json - Add .husky/pre-push hook to run backend tests before push - Update package.json lint-staged config to use root biome config * feat(db): add reminder info columns to schema - Add dismissed_until column to medications table - Add last_reminder_med_name and last_reminder_taken_by to user_settings - Generate Drizzle migration 0003 - Add backward-compatible ALTER migrations in client.ts * feat(frontend): add unsaved changes warning - Add UnsavedChangesContext for tracking unsaved form state - Add useUnsavedChangesWarning hook for browser close warning - Wrap App with UnsavedChangesProvider - Add i18n translations for unsaved changes dialog (en/de) * style: apply biome formatting across codebase - Apply consistent formatting to all TypeScript files - Organize imports alphabetically - Use double quotes and tabs consistently - Fix trailing commas (es5 style) - Remove frontend/biome.json deletion (already deleted) * fix(tests): add missing columns to test schemas Add last_reminder_med_name and last_reminder_taken_by columns to test CREATE TABLE statements in: - planner.test.ts - e2e-routes.test.ts - integration.test.ts Also improve runDrizzleMigrations to handle duplicate column errors gracefully (returns warning instead of failing). * fix(planner): add missing 'as unknown' type cast for request.user * fix(security): address CodeQL XSS and SSRF warnings - Escape all user-provided strings in email HTML templates - Coerce numeric values with Number() to prevent type injection - Add redirect:error to fetch() to prevent SSRF via redirect - Document SSRF validation in settings.ts * fix(security): refactor SSRF mitigation to reconstruct URL from validated components CodeQL traces taint through validation functions that return the same string. Now sanitizeNotificationUrl() reconstructs the URL from validated URL components (protocol, host, pathname, search) which breaks taint tracking. - Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data - Returns reconstructed URL built from URL() parsed components - Extracts auth credentials separately instead of including in URL string - Added isNtfy flag to avoid re-parsing the sanitized URL * fix(security): add SSRF suppression comment for validated notification URL The fetch() uses a URL that has been validated by sanitizeNotificationUrl(): - Only http/https protocols - Blocks localhost and loopback IPs - Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Blocks internal hostnames (.local, .internal, .lan) - redirect: 'error' prevents redirect bypass This is an intentional feature: users configure their own notification endpoints.
669 lines
14 KiB
TypeScript
669 lines
14 KiB
TypeScript
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SchedulePage } from "../../pages/SchedulePage";
|
|
|
|
// Mock data
|
|
const mockMeds = [
|
|
{
|
|
id: 1,
|
|
name: "Aspirin",
|
|
packCount: 1,
|
|
blistersPerPack: 2,
|
|
pillsPerBlister: 10,
|
|
looseTablets: 5,
|
|
takenBy: ["John"],
|
|
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
|
|
intakeRemindersEnabled: true,
|
|
notes: "Take with food",
|
|
pillWeightMg: 500,
|
|
imageUrl: null,
|
|
updatedAt: null,
|
|
},
|
|
];
|
|
|
|
// Fixed timestamp for consistent tests
|
|
const FIXED_TIMESTAMP = 1706000000000; // Fixed date for testing
|
|
|
|
const mockCoverageByMed = {
|
|
Aspirin: {
|
|
name: "Aspirin",
|
|
medsLeft: 25,
|
|
daysLeft: 25,
|
|
depletionDate: "2025-02-15",
|
|
depletionTime: FIXED_TIMESTAMP + 25 * 86400000,
|
|
nextDose: null,
|
|
},
|
|
};
|
|
|
|
const mockFutureDays = [
|
|
{
|
|
dateStr: "Mon, Jan 22",
|
|
date: new Date(FIXED_TIMESTAMP),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [{ id: `1-0-${FIXED_TIMESTAMP}`, timeStr: "09:00", when: FIXED_TIMESTAMP, usage: 1, takenBy: ["John"] }],
|
|
lastWhen: FIXED_TIMESTAMP,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const mockPastDays = [
|
|
{
|
|
dateStr: "Sun, Jan 21",
|
|
date: new Date(FIXED_TIMESTAMP - 86400000),
|
|
isPast: true,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [
|
|
{
|
|
id: `1-0-${FIXED_TIMESTAMP - 86400000}`,
|
|
timeStr: "09:00",
|
|
when: FIXED_TIMESTAMP - 86400000,
|
|
usage: 1,
|
|
takenBy: ["John"],
|
|
},
|
|
],
|
|
lastWhen: FIXED_TIMESTAMP - 86400000,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
// Factory function for mock context
|
|
const createMockContext = (overrides = {}) => ({
|
|
meds: [],
|
|
settings: {
|
|
lowStockThreshold: 30,
|
|
criticalStockThreshold: 7,
|
|
expiryWarningDays: 30,
|
|
lowStockDays: 7,
|
|
normalStockDays: 30,
|
|
highStockDays: 90,
|
|
},
|
|
scheduleDays: 30,
|
|
setScheduleDays: vi.fn(),
|
|
showPastDays: false,
|
|
setShowPastDays: vi.fn(),
|
|
pastDays: [],
|
|
futureDays: [],
|
|
takenDoses: new Set(),
|
|
markDoseTaken: vi.fn(),
|
|
undoDoseTaken: vi.fn(),
|
|
coverageByMed: {},
|
|
depletionByMed: {},
|
|
manuallyExpandedDays: new Set(),
|
|
toggleDayCollapse: vi.fn(),
|
|
openUserFilter: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
let mockContextValue = createMockContext();
|
|
|
|
// Mock the context
|
|
vi.mock("../../context", () => ({
|
|
useAppContext: () => mockContextValue,
|
|
}));
|
|
|
|
vi.mock("../../components/Auth", () => ({
|
|
useAuth: () => ({
|
|
user: { id: 1, username: "testuser" },
|
|
}),
|
|
}));
|
|
|
|
describe("SchedulePage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockContext();
|
|
});
|
|
|
|
it("renders schedule page", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should render the schedule section
|
|
const section = document.querySelector("section.grid");
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders schedule title", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders day range selector", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have schedule days select dropdown
|
|
const select = document.querySelector(".schedule-days-select");
|
|
expect(select).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders timeline section", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have timeline div
|
|
const timeline = document.querySelector(".timeline");
|
|
expect(timeline).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows empty state when no medications", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// With no meds, should show the schedule card but with empty timeline
|
|
const card = document.querySelector(".card.schedule-full");
|
|
expect(card).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders card head", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const cardHead = document.querySelector(".card-head");
|
|
expect(cardHead).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders schedule days options", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const select = document.querySelector(".schedule-days-select");
|
|
const options = select?.querySelectorAll("option");
|
|
expect(options?.length).toBe(3);
|
|
});
|
|
|
|
it("has 30, 90, 180 day options", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/dashboard\.schedules\.1month/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/dashboard\.schedules\.3months/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/dashboard\.schedules\.6months/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("can change schedule days", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const select = document.querySelector(".schedule-days-select") as HTMLSelectElement;
|
|
expect(select).toBeInTheDocument();
|
|
|
|
fireEvent.change(select, { target: { value: "90" } });
|
|
});
|
|
});
|
|
|
|
describe("SchedulePage structure", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockContext();
|
|
});
|
|
|
|
it("has heading element", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const heading = document.querySelector("h2");
|
|
expect(heading).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders article element", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const article = document.querySelector("article");
|
|
expect(article).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders section element", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const section = document.querySelector("section");
|
|
expect(section).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders card with correct class", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const card = document.querySelector(".card.schedule-full");
|
|
expect(card).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("SchedulePage with medications", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
futureDays: mockFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
depletionByMed: { Aspirin: Date.now() + 25 * 86400000 },
|
|
});
|
|
});
|
|
|
|
it("renders medication in timeline", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText("Aspirin")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders day block", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const dayBlocks = document.querySelectorAll(".day-block");
|
|
expect(dayBlocks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders dose item", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const doseItems = document.querySelectorAll(".dose-item");
|
|
expect(doseItems.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders take button", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const takeBtn = document.querySelector(".dose-btn.take");
|
|
expect(takeBtn).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls markDoseTaken when clicking take button", () => {
|
|
const markDoseTaken = vi.fn();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
futureDays: mockFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
markDoseTaken,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const takeBtn = document.querySelector(".dose-btn.take");
|
|
if (takeBtn) {
|
|
fireEvent.click(takeBtn);
|
|
expect(markDoseTaken).toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("renders person name for dose", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByText("John")).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls openUserFilter when clicking person name", () => {
|
|
const openUserFilter = vi.fn();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
futureDays: mockFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
openUserFilter,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const personName = screen.getByText("John");
|
|
fireEvent.click(personName);
|
|
expect(openUserFilter).toHaveBeenCalledWith("John");
|
|
});
|
|
|
|
it("renders pill weight when available", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Aspirin has pillWeightMg of 500
|
|
expect(screen.getByText(/500 mg/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders reminder icon when enabled", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Aspirin has intakeRemindersEnabled
|
|
const reminderIcon = document.querySelector(".reminder-icon");
|
|
expect(reminderIcon).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders day blocks", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// Should have day blocks rendered
|
|
const dayBlocks = document.querySelectorAll(".day-block");
|
|
expect(dayBlocks.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("SchedulePage with past days", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
pastDays: mockPastDays,
|
|
futureDays: mockFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
showPastDays: false,
|
|
});
|
|
});
|
|
|
|
it("renders past days toggle when past days exist", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const toggle = document.querySelector(".past-days-toggle");
|
|
expect(toggle).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows missed doses warning", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const warning = document.querySelector(".past-days-warning");
|
|
expect(warning).toBeInTheDocument();
|
|
});
|
|
|
|
it("toggles past days visibility", () => {
|
|
const setShowPastDays = vi.fn();
|
|
mockContextValue = createMockContext({
|
|
pastDays: mockPastDays,
|
|
showPastDays: false,
|
|
setShowPastDays,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const toggle = document.querySelector(".past-days-toggle");
|
|
if (toggle) {
|
|
fireEvent.click(toggle);
|
|
expect(setShowPastDays).toHaveBeenCalledWith(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("SchedulePage with expanded past days", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
pastDays: mockPastDays,
|
|
futureDays: mockFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
showPastDays: true,
|
|
manuallyExpandedDays: new Set(["Sun, Jan 21"]),
|
|
});
|
|
});
|
|
|
|
it("renders past day blocks when showPastDays is true", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const pastDayBlocks = document.querySelectorAll(".day-block.past");
|
|
expect(pastDayBlocks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("renders day divider for past days", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const dividers = document.querySelectorAll(".day-divider");
|
|
expect(dividers.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("calls toggleDayCollapse when clicking day divider", () => {
|
|
const toggleDayCollapse = vi.fn();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
pastDays: mockPastDays,
|
|
showPastDays: true,
|
|
manuallyExpandedDays: new Set(["Sun, Jan 21"]),
|
|
coverageByMed: mockCoverageByMed,
|
|
toggleDayCollapse,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const divider = document.querySelector(".day-block.past .day-divider.clickable");
|
|
if (divider) {
|
|
fireEvent.click(divider);
|
|
expect(toggleDayCollapse).toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("SchedulePage with taken doses", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
// Match the dose ID format exactly with the mockFutureDays dose
|
|
// Since we can't predict Date.now(), we make the test check if takenDoses works
|
|
});
|
|
|
|
it("marks doses as taken in UI", () => {
|
|
// Create consistent timestamp for test
|
|
const timestamp = Date.now();
|
|
const doseId = `1-0-${timestamp}-John`;
|
|
|
|
const testFutureDays = [
|
|
{
|
|
dateStr: "Mon, Jan 22",
|
|
date: new Date(timestamp),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [{ id: `1-0-${timestamp}`, timeStr: "09:00", when: timestamp, usage: 1, takenBy: ["John"] }],
|
|
lastWhen: timestamp,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
futureDays: testFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
takenDoses: new Set([doseId]),
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// When dose is taken, the undo button should appear
|
|
const undoBtn = document.querySelector(".dose-btn.undo");
|
|
expect(undoBtn).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls undoDoseTaken when clicking undo button", () => {
|
|
const undoDoseTaken = vi.fn();
|
|
const timestamp = Date.now();
|
|
const doseId = `1-0-${timestamp}-John`;
|
|
|
|
const testFutureDays = [
|
|
{
|
|
dateStr: "Mon, Jan 22",
|
|
date: new Date(timestamp),
|
|
isPast: false,
|
|
meds: [
|
|
{
|
|
medName: "Aspirin",
|
|
total: 1,
|
|
doses: [{ id: `1-0-${timestamp}`, timeStr: "09:00", when: timestamp, usage: 1, takenBy: ["John"] }],
|
|
lastWhen: timestamp,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
futureDays: testFutureDays,
|
|
coverageByMed: mockCoverageByMed,
|
|
takenDoses: new Set([doseId]),
|
|
undoDoseTaken,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const undoBtn = document.querySelector(".dose-btn.undo");
|
|
if (undoBtn) {
|
|
fireEvent.click(undoBtn);
|
|
expect(undoDoseTaken).toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("SchedulePage with low stock", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
mockContextValue = createMockContext({
|
|
meds: mockMeds,
|
|
futureDays: mockFutureDays,
|
|
coverageByMed: {
|
|
Aspirin: {
|
|
name: "Aspirin",
|
|
medsLeft: 3,
|
|
daysLeft: 3,
|
|
depletionDate: "2025-01-25",
|
|
depletionTime: Date.now() + 3 * 86400000,
|
|
nextDose: null,
|
|
},
|
|
},
|
|
depletionByMed: { Aspirin: Date.now() + 3 * 86400000 },
|
|
});
|
|
});
|
|
|
|
it("shows status tag for medications", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<SchedulePage />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
const tags = document.querySelectorAll(".tag");
|
|
expect(tags.length).toBeGreaterThan(0);
|
|
});
|
|
});
|