The selected Space used to live in sessionStorage and ride along invisibly with every collection route. It now lives in the URL: collection pages exist both globally and beneath /spaces/:spaceUid, and the Space context derives its scope from the current location. - Add a Space route branch (Home, Explore, Calendar, Attachments) guarded by a SpaceRoute that shows loading, unavailable, and retry states. - Resolve collection routes through one matchPath-based helper built from the route constants; build Space-scoped paths with buildCollectionPath. - Fetch the route Space directly, seeded from the switcher list so pages are not held behind a second round-trip. - Turn the Space switcher into plain links that preserve the current collection view and query. - Drop the remembered-Space machinery: clearSelectedSpace/selectMemos, the compose-placement policy, routeSupportsCollectionScope, and the quick-find switchToAll flag are all implied by the URL now. - Return from memo details to the full origin URL, including its Space. - Add space.unavailable and space.back-to-memos strings; align the Azerbaijani catalog with English.
106 lines
4.5 KiB
TypeScript
106 lines
4.5 KiB
TypeScript
import { isValidElement } from "react";
|
|
import { matchRoutes, type RouteObject } from "react-router-dom";
|
|
import { describe, expect, it } from "vitest";
|
|
import { ROUTES, routeConfig } from "@/router";
|
|
import { RequireAuthRoute, RequireFullInitializationRoute, RequireGuestRoute, RequireInstanceInitializationRoute } from "@/router/guards";
|
|
import { CALENDAR_ROUTE_PATTERN, SPACE_ROUTE_PATTERN } from "@/router/routes";
|
|
import { SpaceRoute } from "@/router/SpaceRoute";
|
|
|
|
// Walk the nested route config and find the first route with the given path,
|
|
// starting from the provided roots. Returns undefined if nothing matches.
|
|
function findByPath(routes: RouteObject[], path: string): RouteObject | undefined {
|
|
for (const route of routes) {
|
|
if (route.path === path) return route;
|
|
const hit = route.children ? findByPath(route.children, path) : undefined;
|
|
if (hit) return hit;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function elementType(route: RouteObject | undefined): unknown {
|
|
if (!route?.element || !isValidElement(route.element)) return undefined;
|
|
return route.element.type;
|
|
}
|
|
|
|
function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unknown): boolean {
|
|
const walk = (subtree: RouteObject[], ancestorGuards: unknown[]): boolean => {
|
|
for (const route of subtree) {
|
|
const nextAncestors = [...ancestorGuards];
|
|
const type = elementType(route);
|
|
if (type) nextAncestors.push(type);
|
|
if (route.path === path) {
|
|
return nextAncestors.includes(guardType);
|
|
}
|
|
if (route.children && walk(route.children, nextAncestors)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
return walk(routes, []);
|
|
}
|
|
|
|
describe("router configuration", () => {
|
|
it.each([
|
|
"/spaces/product",
|
|
"/spaces/product/explore",
|
|
"/spaces/product/calendar/2026/09",
|
|
"/spaces/product/attachments",
|
|
])("gates %s with authentication and Space access", (path) => {
|
|
const types = matchRoutes(routeConfig, path)?.map(({ route }) => elementType(route));
|
|
expect(types).toContain(RequireAuthRoute);
|
|
expect(types).toContain(SpaceRoute);
|
|
expect(findByPath(routeConfig, SPACE_ROUTE_PATTERN)).toBeDefined();
|
|
});
|
|
it("keeps unknown Space subroutes outside collection pages", () => {
|
|
const matches = matchRoutes(routeConfig, "/spaces/product/unknown");
|
|
expect(matches?.at(-1)?.route.path).toBe("*");
|
|
expect(matches?.map(({ route }) => elementType(route))).not.toContain(SpaceRoute);
|
|
});
|
|
|
|
it("keeps /auth/callback outside the guest-only guard", () => {
|
|
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
|
|
// must not short-circuit the OAuth callback via RequireGuestRoute.
|
|
expect(hasAncestorOfType(routeConfig, "callback", RequireGuestRoute)).toBe(false);
|
|
});
|
|
|
|
it("wraps the remaining /auth children in RequireGuestRoute", () => {
|
|
for (const path of ["", "admin", "signup"]) {
|
|
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
|
|
expect(hasAncestorOfType(routeConfig, path, RequireInstanceInitializationRoute)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("wraps authenticated-only pages in RequireAuthRoute", () => {
|
|
for (const path of [ROUTES.ARCHIVED, CALENDAR_ROUTE_PATTERN, ROUTES.VIEWS, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
|
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("keeps non-feed pages behind their full initialization requirements", () => {
|
|
for (const path of [ROUTES.VIEWS, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
|
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(true);
|
|
}
|
|
expect(hasAncestorOfType(routeConfig, ROUTES.ABOUT, RequireInstanceInitializationRoute)).toBe(true);
|
|
});
|
|
|
|
it("does not register a dedicated new memo route", () => {
|
|
expect(findByPath(routeConfig, "/new")).toBeUndefined();
|
|
});
|
|
|
|
it("leaves memo feeds available for early queries", () => {
|
|
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, CALENDAR_ROUTE_PATTERN, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
|
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("leaves public pages outside RequireAuthRoute", () => {
|
|
for (const path of [ROUTES.ABOUT, ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
|
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("exposes an accessible /auth/callback route definition", () => {
|
|
expect(findByPath(routeConfig, "callback")).toBeTruthy();
|
|
});
|
|
});
|