diff --git a/src/features/about/aboutPage.test.tsx b/src/features/about/aboutPage.test.tsx index 35edc2b..0f34930 100644 --- a/src/features/about/aboutPage.test.tsx +++ b/src/features/about/aboutPage.test.tsx @@ -1,19 +1,26 @@ import { describe, expect, test } from "bun:test"; import type { AppVariables } from "@shared/appVariables.ts"; +import { pageRoutes } from "@shared/pageRoutes.ts"; import { Hono } from "hono"; import { aboutPage } from "./aboutPage.tsx"; describe("aboutPage", () => { test("renders the about page HTML", async () => { - const app = new Hono<{ Variables: AppVariables }>().route("/", aboutPage); + const app = new Hono<{ Variables: AppVariables }>().route( + pageRoutes.ABOUT, + aboutPage, + ); - const response = await app.fetch(new Request("http://localhost/")); + const response = await app.fetch( + new Request(`http://localhost${pageRoutes.ABOUT}`), + ); const html = await response.text(); expect(response.status).toBe(200); expect(html).toContain("ToDo"); expect(html).toContain("

About

"); - expect(html).toContain('Home'); + expect(html).toContain('Login'); + expect(html).not.toContain('About'); expect(html).toContain("

Powered By

"); expect(html).toContain("Name"); expect(html).toContain("Link"); diff --git a/src/features/about/aboutPage.tsx b/src/features/about/aboutPage.tsx index df31707..5c8d4b2 100644 --- a/src/features/about/aboutPage.tsx +++ b/src/features/about/aboutPage.tsx @@ -6,7 +6,7 @@ export const aboutPage = new Hono<{ Variables: AppVariables }>().get( "/", (c) => { return c.html( - +

About

Powered By

diff --git a/src/features/add-todo/addToDoPage.test.tsx b/src/features/add-todo/addToDoPage.test.tsx index 0dbe8ad..3f55213 100644 --- a/src/features/add-todo/addToDoPage.test.tsx +++ b/src/features/add-todo/addToDoPage.test.tsx @@ -14,9 +14,11 @@ describe("addToDoPage", () => { }); const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) - .route("/", addToDoPage); + .route(pageRoutes.ADD_TODO, addToDoPage); - const response = await app.fetch(new Request("http://localhost/")); + const response = await app.fetch( + new Request(`http://localhost${pageRoutes.ADD_TODO}`), + ); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe(pageRoutes.LOGIN); @@ -26,13 +28,17 @@ describe("addToDoPage", () => { const appConfig = createAppConfig({ JWT_SECRET: "12345678901234567890123456789012", }); - const token = await sign({ sub: "admin" }, appConfig.jwt.secret, "HS256"); + const token = await sign( + { sub: "1234", preferred_username: "admin" }, + appConfig.jwt.secret, + "HS256", + ); const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) - .route("/", addToDoPage); + .route(pageRoutes.ADD_TODO, addToDoPage); const response = await app.fetch( - new Request("http://localhost/", { + new Request(`http://localhost${pageRoutes.ADD_TODO}`, { headers: { Cookie: `${appConfig.jwt.cookieName}=${token}`, }, @@ -41,6 +47,12 @@ describe("addToDoPage", () => { const html = await response.text(); expect(response.status).toBe(200); - expect(html).toContain("Add ToDo"); + expect(html).toContain("ToDo"); + expect(html).toContain("

Add ToDo

"); + expect(html).toContain('Home'); + expect(html).not.toContain('Add'); + expect(html).toContain('About'); + expect(html).toContain("
  • admin
  • "); + expect(html).toContain(''); }); }); diff --git a/src/features/add-todo/addToDoPage.tsx b/src/features/add-todo/addToDoPage.tsx index 01617b1..5667569 100644 --- a/src/features/add-todo/addToDoPage.tsx +++ b/src/features/add-todo/addToDoPage.tsx @@ -1,13 +1,16 @@ import type { AppVariables } from "@shared/appVariables.ts"; import { Page } from "@shared/page.tsx"; import { pageJwtMiddleware } from "@shared/pageJwtMiddleware.ts"; +import { getUsernameFromJwtPayload } from "@shared/utility.ts"; import { Hono } from "hono"; export const addToDoPage = new Hono<{ Variables: AppVariables }>() .use("/", pageJwtMiddleware) .get("/", (c) => { + const username = getUsernameFromJwtPayload(c.var.jwtPayload); + return c.html( - +

    Add ToDo

    , ); diff --git a/src/features/home/homePage.test.tsx b/src/features/home/homePage.test.tsx index bc6b32d..8404237 100644 --- a/src/features/home/homePage.test.tsx +++ b/src/features/home/homePage.test.tsx @@ -26,7 +26,11 @@ describe("homePage", () => { const appConfig = createAppConfig({ JWT_SECRET: "12345678901234567890123456789012", }); - const token = await sign({ sub: "admin" }, appConfig.jwt.secret, "HS256"); + const token = await sign( + { sub: "1234", preferred_username: "admin" }, + appConfig.jwt.secret, + "HS256", + ); const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) .route("/", homePage); @@ -43,6 +47,10 @@ describe("homePage", () => { expect(response.status).toBe(200); expect(html).toContain("ToDo"); expect(html).toContain("

    Home

    "); + expect(html).not.toContain('Home'); + expect(html).toContain('Add'); expect(html).toContain('About'); + expect(html).toContain("
  • admin
  • "); + expect(html).toContain(''); }); }); diff --git a/src/features/home/homePage.tsx b/src/features/home/homePage.tsx index 77f8b17..d0f1126 100644 --- a/src/features/home/homePage.tsx +++ b/src/features/home/homePage.tsx @@ -1,13 +1,15 @@ import type { AppVariables } from "@shared/appVariables.ts"; import { Page } from "@shared/page.tsx"; import { pageJwtMiddleware } from "@shared/pageJwtMiddleware.ts"; +import { getUsernameFromJwtPayload } from "@shared/utility.ts"; import { Hono } from "hono"; export const homePage = new Hono<{ Variables: AppVariables }>() .use("/", pageJwtMiddleware) .get("/", (c) => { + const username = getUsernameFromJwtPayload(c.var.jwtPayload); return c.html( - +

    Home

    , ); diff --git a/src/features/login/loginPage.test.tsx b/src/features/login/loginPage.test.tsx index 6af0fd3..c492feb 100644 --- a/src/features/login/loginPage.test.tsx +++ b/src/features/login/loginPage.test.tsx @@ -1,14 +1,20 @@ import { describe, expect, test } from "bun:test"; import { apiRoutes } from "@shared/apiRoutes.ts"; import type { AppVariables } from "@shared/appVariables.ts"; +import { pageRoutes } from "@shared/pageRoutes.ts"; import { Hono } from "hono"; import { loginPage } from "./loginPage.tsx"; describe("loginPage", () => { test("renders the login page HTML", async () => { - const app = new Hono<{ Variables: AppVariables }>().route("/", loginPage); + const app = new Hono<{ Variables: AppVariables }>().route( + pageRoutes.LOGIN, + loginPage, + ); - const response = await app.fetch(new Request("http://localhost/")); + const response = await app.fetch( + new Request(`http://localhost${pageRoutes.LOGIN}`), + ); const html = await response.text(); expect(response.status).toBe(200); @@ -25,6 +31,8 @@ describe("loginPage", () => { ); expect(html).toContain('autocomplete="current-password"'); expect(html).toContain(''); + expect(html).not.toContain('Login'); + expect(html).toContain('About'); expect(html).toContain(apiRoutes.LOGIN); expect(html).toContain("contentType: 'form'"); }); diff --git a/src/features/login/loginPage.tsx b/src/features/login/loginPage.tsx index c0d4609..3639900 100644 --- a/src/features/login/loginPage.tsx +++ b/src/features/login/loginPage.tsx @@ -7,7 +7,7 @@ export const loginPage = new Hono<{ Variables: AppVariables }>().get( "/", (c) => { return c.html( - +

    Login

    { + test("renders authenticated navigation links, username, and logout action", () => { + const html = NavigationBar({ + type: "authenticated", + currentPath: "/add-todo", + username: "test-user", + }); + const htmlString = String(html); + + expect(htmlString).toContain(" - ); +export type SharedNavigationBarProps = Readonly<{ + currentPath: string; +}>; + +export type AuthenticatedNavigationBarProps = SharedNavigationBarProps & + Readonly<{ + type: "authenticated"; + username: string; + }>; + +export type UnauthenticatedNavigationBarProps = SharedNavigationBarProps & + Readonly<{ + type: "unauthenticated"; + }>; + +export type NavigationBarProps = + | AuthenticatedNavigationBarProps + | UnauthenticatedNavigationBarProps; + +export function NavigationBar(props: NavigationBarProps) { + switch (props.type) { + case "authenticated": + return ( + + ); + case "unauthenticated": + return ( + + ); + default: { + const _unknownType: never = props; + return _unknownType; + } + } } diff --git a/src/shared/navigationBarItem.test.tsx b/src/shared/navigationBarItem.test.tsx new file mode 100644 index 0000000..cb897b4 --- /dev/null +++ b/src/shared/navigationBarItem.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { NavigationBarItem } from "./navigationBarItem.tsx"; + +describe("NavigationBarItem", () => { + test("renders a link item when type is link", () => { + const html = NavigationBarItem({ + type: "link", + label: "About", + url: "/about", + currentPath: "/", + }); + const htmlString = String(html); + + expect(htmlString).toContain("
  • "); + expect(htmlString).toContain('About'); + expect(htmlString).toContain("
  • "); + }); + + test("renders a list item with provided children when type is generic", () => { + const html = NavigationBarItem({ + type: "generic", + children: "Custom content", + }); + const htmlString = String(html); + + expect(htmlString).toContain("
  • Custom content
  • "); + }); + + test("returns null when type is generic and children are not provided", () => { + const html = NavigationBarItem({ type: "generic" }); + + expect(html).toBeNull(); + }); + + test("returns null when link URL matches current path", () => { + const html = NavigationBarItem({ + type: "link", + label: "About", + url: "/about", + currentPath: "/about", + }); + + expect(html).toBeNull(); + }); +}); diff --git a/src/shared/navigationBarItem.tsx b/src/shared/navigationBarItem.tsx new file mode 100644 index 0000000..f760f10 --- /dev/null +++ b/src/shared/navigationBarItem.tsx @@ -0,0 +1,32 @@ +import type { Child } from "hono/jsx"; + +type NavigationBarItemProps = + | Readonly<{ + type: "link"; + label: string; + url: string; + currentPath: string; + }> + | Readonly<{ + type: "generic"; + children?: Child; + }>; + +export function NavigationBarItem(props: NavigationBarItemProps) { + switch (props.type) { + case "link": + return props.url === props.currentPath ? null : ( +
  • + {props.label} +
  • + ); + case "generic": + return props.children !== null && props.children !== undefined ? ( +
  • {props.children}
  • + ) : null; + default: { + const _unknownType: never = props; + return _unknownType; + } + } +} diff --git a/src/shared/page.test.tsx b/src/shared/page.test.tsx index 580b6ab..49f2bea 100644 --- a/src/shared/page.test.tsx +++ b/src/shared/page.test.tsx @@ -1,30 +1,55 @@ import { describe, expect, test } from "bun:test"; import { Page } from "./page.tsx"; +const unauthenticatedPageProps = { type: "unauthenticated" } as const; +const authenticatedPageProps = { + type: "authenticated", + username: "test-user", +} as const; + describe("Page", () => { test("renders with correct title", () => { - const html = Page({ title: "Test Page", children: null }); + const html = Page({ + ...unauthenticatedPageProps, + title: "Test Page", + children: null, + currentPath: "/test-page", + }); const htmlString = String(html); expect(htmlString).toContain("Test Page"); }); test("renders the default title when title is omitted", () => { - const html = Page({ children: null }); + const html = Page({ + ...unauthenticatedPageProps, + currentPath: "/todo", + children: null, + }); const htmlString = String(html); expect(htmlString).toContain("ToDo"); }); test("renders with correct lang attribute", () => { - const html = Page({ title: "Home", children: null }); + const html = Page({ + ...unauthenticatedPageProps, + title: "Home", + children: null, + currentPath: "/home", + }); const htmlString = String(html); expect(htmlString).toContain(''); }); test("renders with correct HTML structure", () => { - const html = Page({ title: "Test", children: null }); + const html = Page({ + ...unauthenticatedPageProps, + title: "Test", + children: null, + currentPath: "/test", + }); const htmlString = String(html); expect(htmlString).toContain(""); @@ -40,4 +65,35 @@ describe("Page", () => { expect(htmlString).toContain(""); expect(htmlString).toContain(""); }); + + test("hides current unauthenticated route from navigation", () => { + const html = Page({ + ...unauthenticatedPageProps, + currentPath: "/about", + children: null, + }); + const htmlString = String(html); + + expect(htmlString).toContain('Login'); + expect(htmlString).not.toContain('About'); + expect(htmlString).not.toContain("Logout"); + }); + + test("hides current authenticated route from navigation", () => { + const html = Page({ + ...authenticatedPageProps, + currentPath: "/", + children: null, + }); + const htmlString = String(html); + + expect(htmlString).not.toContain('Home'); + expect(htmlString).toContain('Add'); + expect(htmlString).toContain('About'); + expect(htmlString).toContain("
  • test-user
  • "); + expect(htmlString).toContain('
    '); + expect(htmlString).toContain( + '