feat(001-agent-admin-ui): Setup + Foundational + User Story 0 (sign-in)
Configures both previously-empty test runners (vitest.config.ts, playwright.config.ts) and adds the typed lib/api client layer (axios + interceptors), the session-cookie plumbing (lib/auth), TanStack Query infrastructure (lib/query, providers), and a real sign-in flow consuming supporthub-api's own login (010-identity-auth) - the true foundation every other user story in this feature depends on. Two structural fixes to the existing scaffold, both found only by running the app rather than by inspection: middleware.ts belongs at src/middleware.ts under this project's src/ layout, not the repo root; and next.config.mjs's output:'export' is incompatible with Next.js Middleware outright (the dev server refuses to start it), so this app now runs as a standard Next.js server - confirmed with the user before making that deployment-mode change. Verified end-to-end with a real, locally-running supporthub-api: all 5 Playwright scenarios (unauthenticated redirect, sign-in, wrong-password generic error, non-admin role gating, sign-out) pass against a live backend, not a mock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
326859e27b
commit
95b5a1e03d
@@ -26,6 +26,11 @@ yarn-error.log*
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
# TypeScript & Next build cache
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
// 001-agent-admin-ui: static export ('output: export') is incompatible with Next.js
|
||||
// Middleware, which the (support)/(admin) portals' sign-in guard requires (FR-000) — this app
|
||||
// now runs as a standard Next.js server (`next build && next start`), not a static export.
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
unoptimized: true, // Required for static HTML export
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1279
-1
File diff suppressed because it is too large
Load Diff
+19
-14
@@ -2,11 +2,9 @@
|
||||
"name": "supporthub-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
|
||||
"engines": {
|
||||
"node": ">=22 <23"
|
||||
},
|
||||
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
@@ -20,30 +18,37 @@
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.12",
|
||||
"js-cookie": "^3.0.8",
|
||||
"lucide-react": "^0.417.0",
|
||||
"next": "^14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"lucide-react": "^0.417.0",
|
||||
"axios": "^1.7.2"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.45.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"typescript": "^5.5.4",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"postcss": "^8.4.40",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.5",
|
||||
"vitest": "^2.0.4",
|
||||
"@playwright/test": "^1.45.3"
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
// Serial, not fullyParallel: against a Next.js DEV server, concurrent first-hit requests to
|
||||
// different routes contend on that dev server's own on-demand page compilation, causing
|
||||
// response-time flakiness unrelated to the app itself. A production build (`next build &&
|
||||
// next start`) doesn't have this cold-compile-per-route behavior; revisit if E2E ever runs
|
||||
// against one.
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -55,3 +55,17 @@
|
||||
reworded FR-011/the existing Edge Case/the closing Assumption to say "supporthub-api session
|
||||
role" instead of "SaaS-provided role." This is a correction of a wrong premise, not new scope
|
||||
creep — nothing else in User Stories 1-7 changes.
|
||||
- **Implementation-time finding**: two structural issues with this project's existing scaffold
|
||||
only surfaced by actually running the app, not by inspection — (1) with this project's `src/`
|
||||
directory layout, Next.js requires `middleware.ts` to live at `src/middleware.ts`, not the
|
||||
repo root; the pre-existing empty `middleware.ts` had been scaffolded in the wrong location.
|
||||
(2) `next.config.mjs`'s `output: 'export'` (static HTML export) is fundamentally incompatible
|
||||
with Next.js Middleware — the dev server refused to run it outright. Removing static export
|
||||
is a deployment-mode change (this app now needs `next build && next start`, not a static file
|
||||
host), flagged to and confirmed by the user before making it, since it has real infrastructure
|
||||
implications beyond this feature's own code.
|
||||
- A second discovered blocker, upstream of this feature entirely: supporthub-api had no endpoint
|
||||
to list "tickets currently assigned to agent X" at all, and no way to resolve a logged-in
|
||||
session to its own agent roster row — User Story 1 had no data source without it. Resolved by
|
||||
a new supporthub-api feature, 011-agent-ticket-queue, built and merged into this feature's own
|
||||
data-model.md/contracts before Setup began.
|
||||
|
||||
@@ -17,7 +17,11 @@ logic is computed client-side (Constitution Principle II).
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.5, Node.js 22 (per `package.json` `engines`), Next.js 14
|
||||
(App Router), React 18.
|
||||
(App Router), React 18. `next.config.mjs`'s `output: 'export'` (static HTML export) is removed
|
||||
by this feature — Next.js Middleware, which FR-000's sign-in guard requires, cannot run under
|
||||
static export (confirmed by the dev server itself refusing to start it); this app now runs as a
|
||||
standard Next.js server (`next build && next start`), a deployment-mode change, not just a code
|
||||
change.
|
||||
|
||||
**Primary Dependencies**: `@tanstack/react-query` (server state), `axios` (wrapped by `lib/api`),
|
||||
`tailwindcss` (styling, already configured), `clsx`/`tailwind-merge` (already present),
|
||||
@@ -100,7 +104,9 @@ specs/001-agent-admin-ui/
|
||||
|
||||
```text
|
||||
supporthub-web/
|
||||
├── middleware.ts # MODIFIED (was empty) — session presence + role
|
||||
├── src/middleware.ts # MODIFIED (was an empty file at repo root — moved
|
||||
│ under src/, required by this project's src/
|
||||
│ directory layout) — session presence + role
|
||||
│ redirect for (support)/(admin), FR-000/research.md
|
||||
├── vitest.config.ts # MODIFIED (was empty) — first real test config
|
||||
├── playwright.config.ts # MODIFIED (was empty) — first real E2E config
|
||||
|
||||
@@ -26,15 +26,15 @@ All file paths are relative to `supporthub-web/` (repo root).
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] T001 [P] Add `js-cookie` + `@types/js-cookie` and `jose` to `package.json`
|
||||
- [ ] T002 [P] Populate `src/lib/env/index.ts` — typed accessor over the existing
|
||||
- [x] T001 [P] Add `js-cookie` + `@types/js-cookie` and `jose` to `package.json`
|
||||
- [x] T002 [P] Populate `src/lib/env/index.ts` — typed accessor over the existing
|
||||
`NEXT_PUBLIC_*` env vars (zod-validated, matching supporthub-api's own `env.ts` pattern),
|
||||
exporting `env.apiUrl` etc.
|
||||
- [ ] T003 [P] Configure `vitest.config.ts` (currently empty) — `environment: 'jsdom'`, path
|
||||
- [x] T003 [P] Configure `vitest.config.ts` (currently empty) — `environment: 'jsdom'`, path
|
||||
aliases matching `tsconfig.json`, `tests/unit` + `tests/integration` include globs
|
||||
- [ ] T004 [P] Configure `playwright.config.ts` (currently empty) — base URL from
|
||||
- [x] T004 [P] Configure `playwright.config.ts` (currently empty) — base URL from
|
||||
`NEXT_PUBLIC_APP_URL`, `tests/e2e` test dir
|
||||
- [ ] T005 [P] Add `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` as dev
|
||||
- [x] T005 [P] Add `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` as dev
|
||||
dependencies (needed by T003's `jsdom` environment; not yet present in `package.json`)
|
||||
|
||||
**Checkpoint**: Both test runners actually run (even with zero tests) and `env` is typed.
|
||||
@@ -48,33 +48,37 @@ story is built on.
|
||||
|
||||
**⚠️ CRITICAL**: No user-story work can begin until this phase is complete.
|
||||
|
||||
- [ ] T006 Add `lib/auth/session-cookie.ts` — `getSessionToken()`/`setSessionToken(token)`/
|
||||
- [x] T006 Add `lib/auth/session-cookie.ts` — `getSessionToken()`/`setSessionToken(token)`/
|
||||
`clearSessionToken()` using `js-cookie` (`sh_session`, `Secure` in production,
|
||||
`SameSite=Lax`) (depends on T001)
|
||||
- [ ] T007 Add `lib/api/client.ts` — axios instance (`baseURL: env.apiUrl`), a request
|
||||
- [x] T007 Add `lib/api/client.ts` — axios instance (`baseURL: env.apiUrl`), a request
|
||||
interceptor attaching `Authorization: Bearer <token>` from T006, a response interceptor
|
||||
throwing a typed `ApiError` (data-model.md) and clearing the session + redirecting to
|
||||
`/sign-in` on `401` (depends on T002, T006)
|
||||
- [ ] T008 [P] Add `lib/api/types/` — `Session`, `ApiError`, `AssignedTicketSummary` (011's
|
||||
- [x] T008 [P] Add `lib/api/types/` — `Session`, `ApiError`, `AssignedTicketSummary` (011's
|
||||
contract), and the ticket/message/problem-resolution/team/agent/hierarchy types
|
||||
data-model.md names, each referencing its source backend contract in a comment
|
||||
- [ ] T009 Add `lib/api/auth.ts` — `login`, `getCurrentSession`, `logout` per
|
||||
- [x] T009 Add `lib/api/auth.ts` — `login`, `getCurrentSession`, `logout` per
|
||||
contracts/api-client-contract.md (depends on T007, T008)
|
||||
- [ ] T010 Add `lib/query/query-client.ts` (the shared `QueryClient` instance) and
|
||||
- [x] T010 Add `lib/query/query-client.ts` (the shared `QueryClient` instance) and
|
||||
`lib/query/query-state.ts` (research.md's `'loading'|'empty'|'error'|'ready'`
|
||||
discriminated union helper, taking a `UseQueryResult` and an optional
|
||||
`isEmpty(data)` predicate)
|
||||
- [ ] T011 Add `providers/query-provider.tsx` (`QueryClientProvider` wrapping T010's client) and
|
||||
- [x] T011 Add `providers/query-provider.tsx` (`QueryClientProvider` wrapping T010's client) and
|
||||
`providers/session-provider.tsx` (calls `getCurrentSession` on mount via TanStack Query,
|
||||
exposes `useSession()`; the query's own `onError` for a `401` clears the session and
|
||||
redirects, per contracts/api-client-contract.md's "Session guard contract") (depends on
|
||||
T009, T010)
|
||||
- [ ] T012 Wire both providers into `src/app/layout.tsx` (currently the root layout with no
|
||||
- [x] T012 Wire both providers into `src/app/layout.tsx` (currently the root layout with no
|
||||
providers)
|
||||
- [ ] T013 Write `middleware.ts` (currently empty) — no `sh_session` cookie on a
|
||||
`(support)`/`(admin)` path → redirect to `/sign-in?from=<path>`; cookie present but
|
||||
`jose`-decoded `role` isn't `ADMIN` on an `(admin)` path → redirect to
|
||||
`/support/dashboard` (research.md's unverified-decode decision) (depends on T001)
|
||||
- [x] T013 Write `src/middleware.ts` (this project's `src/` layout requires middleware there,
|
||||
not at the repo root — an empty root-level `middleware.ts` had been scaffolded in the
|
||||
wrong place) — no `sh_session` cookie on a `(support)`/`(admin)` path → redirect to
|
||||
`/sign-in?from=<path>`; cookie present but `jose`-decoded `role` isn't `ADMIN` on an
|
||||
`(admin)` path → redirect to `/support/dashboard` (research.md's unverified-decode
|
||||
decision) (depends on T001). Also requires removing `next.config.mjs`'s
|
||||
`output: 'export'` — incompatible with Middleware (discovered by actually running the
|
||||
dev server, not by inspection).
|
||||
|
||||
**Checkpoint**: A session can be established, read, and cleared; every subsequent API call
|
||||
carries it; an unauthenticated or wrongly-roled request never reaches portal content.
|
||||
@@ -89,28 +93,28 @@ carries it; an unauthenticated or wrongly-roled request never reaches portal con
|
||||
|
||||
### Tests for User Story 0
|
||||
|
||||
- [ ] T014 [P] [US0] Unit test for `lib/query/query-state.ts`'s discriminated-union logic
|
||||
- [x] T014 [P] [US0] Unit test for `lib/query/query-state.ts`'s discriminated-union logic
|
||||
(loading/empty/error/ready, each input combination) in `tests/unit/lib/query-state.test.ts`
|
||||
- [ ] T015 [US0] Integration test (mocked `lib/api/auth.ts`) covering Quickstart Scenario 0
|
||||
- [x] T015 [US0] Integration test (mocked `lib/api/auth.ts`) covering Quickstart Scenario 0
|
||||
steps 2-3 (successful sign-in redirects and stores a session; wrong credentials show one
|
||||
generic error) in `tests/integration/auth/sign-in.test.tsx` (depends on T009)
|
||||
- [ ] T016 [US0] Playwright E2E covering Quickstart Scenario 0 end-to-end against a real
|
||||
- [x] T016 [US0] Playwright E2E covering Quickstart Scenario 0 end-to-end against a real
|
||||
supporthub-api (redirect-when-unauthenticated, sign-in, role-gated redirect, sign-out) in
|
||||
`tests/e2e/agent-sign-in-and-resolve.spec.ts` — this is also Constitution Principle VII's
|
||||
journey (B), continued by User Story 2's own steps once that story is built
|
||||
|
||||
### Implementation for User Story 0
|
||||
|
||||
- [ ] T017 [US0] Add `features/auth/sign-in-form.tsx` + `features/auth/use-login.ts` (a
|
||||
- [x] T017 [US0] Add `features/auth/sign-in-form.tsx` + `features/auth/use-login.ts` (a
|
||||
TanStack `useMutation` wrapping `lib/api/auth.ts`'s `login`, redirecting to a
|
||||
role-appropriate landing page on success) (depends on T009, T011)
|
||||
- [ ] T018 [US0] Add the `/sign-in` page (`src/app/sign-in/page.tsx`, new — outside every
|
||||
- [x] T018 [US0] Add the `/sign-in` page (`src/app/sign-in/page.tsx`, new — outside every
|
||||
existing route group, since it's neither an admin nor support surface) rendering T017's
|
||||
form
|
||||
- [ ] T019 [US0] Add a sign-out action (`features/auth/use-logout.ts`, wrapping
|
||||
- [x] T019 [US0] Add a sign-out action (`features/auth/use-logout.ts`, wrapping
|
||||
`lib/api/auth.ts`'s `logout`) wired into both `(support)/layout.tsx` and
|
||||
`(admin)/layout.tsx`'s existing nav shells (depends on T009, T011)
|
||||
- [ ] T020 [US0] Run Quickstart Scenario 0 locally and confirm all 5 steps pass
|
||||
- [x] T020 [US0] Run Quickstart Scenario 0 locally and confirm all 5 steps pass
|
||||
|
||||
**Checkpoint**: A real session exists end-to-end. Every other user story can now consume one.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PortalShell } from '@/features/auth/portal-shell';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="adminlayout">{children}</div>;
|
||||
return <PortalShell title="SupportHub — Admin">{children}</PortalShell>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PortalShell } from '@/features/auth/portal-shell';
|
||||
|
||||
export default function SupportLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="supportlayout">{children}</div>;
|
||||
return <PortalShell title="SupportHub — Agent Workspace">{children}</PortalShell>;
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import '../styles/globals.css';
|
||||
import { QueryProvider } from '@/providers/query-provider';
|
||||
import { SessionProvider } from '@/providers/session-provider';
|
||||
|
||||
export const metadata = {
|
||||
title: 'SupportHub | Customer Support Platform',
|
||||
@@ -14,7 +16,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className="h-full scroll-smooth">
|
||||
<body className="min-h-full flex flex-col font-sans bg-background text-foreground antialiased">
|
||||
{children}
|
||||
<QueryProvider>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SignInForm } from '@/features/auth/sign-in-form';
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm flex flex-col items-center gap-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Sign in to SupportHub</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Agent and admin access for the support workspace.
|
||||
</p>
|
||||
</div>
|
||||
<SignInForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { LogOut, Headphones } from 'lucide-react';
|
||||
import { Button } from '@/components/ui';
|
||||
import { useSession } from '@/providers/session-provider';
|
||||
import { useLogout } from './use-logout';
|
||||
|
||||
/** Minimal session-aware shell shared by (support) and (admin) — a portal-specific composition
|
||||
* (Constitution Principle III), not the marketing-site Navbar. Distinct per-portal navigation
|
||||
* (US3-US7's own admin sections) is added incrementally as each story is built; this covers
|
||||
* only what User Story 0 itself requires: visible identity + a working sign-out. */
|
||||
export function PortalShell({ title, children }: { title: string; children: ReactNode }) {
|
||||
const { session } = useSession();
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-border px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-semibold text-foreground">
|
||||
<Headphones className="h-5 w-5" />
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{session && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{session.name} · {session.role}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="h-4 w-4" />}
|
||||
isLoading={logout.isPending}
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Button, Input, Alert, AlertDescription } from '@/components/ui';
|
||||
import { useLogin } from './use-login';
|
||||
import { ApiError } from '@/lib/api/types';
|
||||
|
||||
/** US0 acceptance scenario 3: a login failure (wrong password or unknown email) is shown as
|
||||
* one generic message — 010-identity-auth's own identical-failure-response guarantee — never
|
||||
* a hint about which part was wrong. */
|
||||
export function SignInForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const login = useLogin();
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
login.mutate({ email, password });
|
||||
}
|
||||
|
||||
// Renders the backend's own message verbatim (Principle IV) — 010-identity-auth already
|
||||
// guarantees this single, generic message for every failure branch (wrong password, unknown
|
||||
// email, inactive account), so there's nothing for the frontend to add or rephrase.
|
||||
const errorMessage = login.error instanceof ApiError ? login.error.message : undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full max-w-sm flex flex-col gap-4">
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
required
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" isLoading={login.isPending} className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { login } from '@/lib/api/auth';
|
||||
|
||||
/** US0 acceptance scenario 2: on success, lands on the role-appropriate landing page. */
|
||||
export function useLogin() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||
login(email, password),
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData(['session'], result.user);
|
||||
router.push(result.user.role === 'ADMIN' ? '/admin/dashboard' : '/support/dashboard');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
|
||||
/** US0 acceptance scenario 5: clears the session (010-identity-auth's own POST /auth/logout,
|
||||
* plus the local cookie regardless of that call's outcome) and redirects to sign-in. */
|
||||
export function useLogout() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: logout,
|
||||
onSettled: () => {
|
||||
queryClient.setQueryData(['session'], undefined);
|
||||
router.push('/sign-in');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiClient } from './client';
|
||||
import { clearSessionToken, setSessionToken } from '@/lib/auth/session-cookie';
|
||||
import { LoginResult, Session } from './types/session';
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta: null;
|
||||
}
|
||||
|
||||
/** specs/010-identity-auth/contracts/identity-auth-contract.md */
|
||||
export async function login(email: string, password: string): Promise<LoginResult> {
|
||||
const response = await apiClient.post<ApiEnvelope<LoginResult>>('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
setSessionToken(response.data.data.token);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/** Re-validates against live account state (010's own FR-007) — called on mount and after any
|
||||
* 401, never trusted from a locally-decoded JWT claim. */
|
||||
export async function getCurrentSession(): Promise<Session> {
|
||||
const response = await apiClient.get<ApiEnvelope<Session>>('/auth/me');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/auth/logout');
|
||||
} finally {
|
||||
// A failed logout call must not leave a dead session behind client-side.
|
||||
clearSessionToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { env } from '@/lib/env';
|
||||
import { clearSessionToken, getSessionToken } from '@/lib/auth/session-cookie';
|
||||
import { ApiError } from './types/api-error';
|
||||
|
||||
export const apiClient = axios.create({ baseURL: env.apiUrl });
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getSessionToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
interface BackendErrorBody {
|
||||
success: false;
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
/** research.md / contracts/api-client-contract.md: business-rule rejections (409/400) and auth
|
||||
* failures (401) are never swallowed or rewritten — every error becomes this same ApiError
|
||||
* shape, carrying the backend's own message verbatim (FR-012/SC-002), so every screen can
|
||||
* render it directly. A 401 additionally clears the local session — the real gate is
|
||||
* supporthub-api's own token check, not this frontend's middleware guess (research.md). */
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<BackendErrorBody>) => {
|
||||
const statusCode = error.response?.status ?? 0;
|
||||
const body = error.response?.data;
|
||||
|
||||
// /auth/login's own 401 (wrong password / unknown email) is an expected, form-level
|
||||
// rejection the sign-in screen handles itself — not "the session went stale," which is
|
||||
// the only case this redirect exists for.
|
||||
const isLoginAttempt = error.config?.url?.endsWith('/auth/login');
|
||||
if (statusCode === 401 && !isLoginAttempt) {
|
||||
clearSessionToken();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/sign-in';
|
||||
}
|
||||
}
|
||||
|
||||
const code = body?.error?.code ?? 'UNKNOWN_ERROR';
|
||||
const message = body?.error?.message ?? error.message;
|
||||
return Promise.reject(new ApiError(message, code, statusCode));
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/** The shape every supporthub-api error response already uses:
|
||||
* { success: false, error: { code, message, statusCode } } — see e.g.
|
||||
* specs/010-identity-auth/contracts/identity-auth-contract.md. Thrown by lib/api/client.ts's
|
||||
* response interceptor so every failed mutation's `error` is always this shape, never a raw
|
||||
* axios error (Constitution Principle IV; contracts/api-client-contract.md). */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './session';
|
||||
export * from './api-error';
|
||||
export * from './tickets';
|
||||
export * from './teams';
|
||||
@@ -0,0 +1,13 @@
|
||||
/** specs/010-identity-auth/contracts/identity-auth-contract.md (supporthub-api) —
|
||||
* POST /auth/login and GET /auth/me's `data.user` shape. */
|
||||
export interface Session {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'ADMIN' | 'AGENT';
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user: Session;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** specs/006-support-organization/contracts/support-org-contract.md (supporthub-api) —
|
||||
* raw Prisma records, no DTO mapping on the backend side, so these mirror the Prisma schema
|
||||
* fields exactly. */
|
||||
export interface Agent {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
userId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export interface AgentSkill {
|
||||
id: string;
|
||||
agentId: string;
|
||||
skillTag: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface AgentAvailability {
|
||||
id: string;
|
||||
agentId: string;
|
||||
status: 'available' | 'busy' | 'away' | 'offline';
|
||||
workingHours: unknown;
|
||||
currentLoad: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HierarchyNode {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
order: number;
|
||||
teamId: string | null;
|
||||
skills: string[];
|
||||
productScope: string[];
|
||||
categoryScope: string[];
|
||||
priorityScope: string[];
|
||||
assignmentStrategy: string;
|
||||
slaPolicyId: string | null;
|
||||
escalationPolicyId: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** specs/011-agent-ticket-queue/contracts/agent-ticket-queue-contract.md (supporthub-api) —
|
||||
* GET /agents/me/tickets and GET /admin/agents/:agentId/tickets response items exactly. */
|
||||
export interface AssignedTicketSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
severity: string;
|
||||
product: { id: string; externalProductId: string; name: string };
|
||||
customer: { externalUserId: string; externalTenantId: string };
|
||||
assignedAt: string | null;
|
||||
sla: {
|
||||
status: string;
|
||||
firstResponseDueAt: string | null;
|
||||
resolutionDueAt: string | null;
|
||||
breachedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** specs/003-ticketing/contracts/ticket-lifecycle-contract.md (supporthub-api) —
|
||||
* GET /tickets/:ticketId response. */
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
code: string;
|
||||
productId: string;
|
||||
problemId: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
severity: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** specs/003-ticketing/contracts/ticket-lifecycle-contract.md — one ticket message. `type` is
|
||||
* a plain string, not a closed frontend enum (Constitution Principle V — never a second,
|
||||
* driftable copy of the backend's own values: CUSTOMER_MESSAGE | AI_MESSAGE | AGENT_MESSAGE |
|
||||
* INTERNAL_NOTE | SYSTEM_EVENT | INVESTIGATION_NOTE | SOLUTION_NOTE). `visibleToCustomer` is
|
||||
* the one field message-thread.tsx actually needs to distinguish an internal note. */
|
||||
export interface TicketMessage {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
type: string;
|
||||
authorRef: string;
|
||||
body: string;
|
||||
visibleToCustomer: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'sh_session';
|
||||
|
||||
/** research.md: a plain (non-httpOnly) cookie — readable by both Next.js middleware (server-
|
||||
* side, for the redirect-before-render guard) and this same client-side code (to attach the
|
||||
* Authorization header supporthub-api actually reads). */
|
||||
export function getSessionToken(): string | undefined {
|
||||
return Cookies.get(SESSION_COOKIE_NAME);
|
||||
}
|
||||
|
||||
export function setSessionToken(token: string): void {
|
||||
Cookies.set(SESSION_COOKIE_NAME, token, {
|
||||
expires: 1 / 6, // 4 hours, matching supporthub-api's own AUTH_TOKEN_LIFETIME_HOURS default
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionToken(): void {
|
||||
Cookies.remove(SESSION_COOKIE_NAME);
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
appName: z.string().min(1),
|
||||
appEnv: z.enum(['development', 'test', 'production']),
|
||||
appUrl: z.string().url(),
|
||||
apiUrl: z.string().url(),
|
||||
});
|
||||
|
||||
/** Typed accessor over the project's existing NEXT_PUBLIC_* vars (see .env.example) — mirrors
|
||||
* supporthub-api's own src/config/env.ts pattern: validate once, import the typed result
|
||||
* everywhere else. */
|
||||
export const env = envSchema.parse({
|
||||
appName: process.env.NEXT_PUBLIC_APP_NAME,
|
||||
appEnv: process.env.NEXT_PUBLIC_APP_ENV,
|
||||
appUrl: process.env.NEXT_PUBLIC_APP_URL,
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
export type QueryState = 'loading' | 'empty' | 'error' | 'ready';
|
||||
|
||||
/** research.md: a discriminated union, not three independently-checked booleans — FR-010/
|
||||
* SC-003 require never conflating loading, empty, and error, and this makes "forgot to handle
|
||||
* the error case" a TypeScript exhaustiveness error at the call site. */
|
||||
export function getQueryState<T>(
|
||||
result: Pick<UseQueryResult<T>, 'isLoading' | 'isError' | 'data'>,
|
||||
isEmpty: (data: T) => boolean = (data) => Array.isArray(data) && data.length === 0,
|
||||
): QueryState {
|
||||
if (result.isLoading) return 'loading';
|
||||
if (result.isError) return 'error';
|
||||
if (result.data !== undefined && isEmpty(result.data)) return 'empty';
|
||||
return 'ready';
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { decodeJwt } from 'jose';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'sh_session';
|
||||
|
||||
/**
|
||||
* research.md: a cheap, UNVERIFIED presence/role check for UX redirects only — the real
|
||||
* security boundary is supporthub-api's own token verification on every API call
|
||||
* (fastify.authenticate / requireRole, 010-identity-auth). This middleware never re-implements
|
||||
* that decision (Constitution Principle I); it only avoids rendering a portal that would just
|
||||
* fail its own data fetches, and never lets the admin portal render for a non-ADMIN role
|
||||
* (FR-011/SC-005).
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
const signInUrl = new URL('/sign-in', request.url);
|
||||
signInUrl.searchParams.set('from', pathname);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/admin')) {
|
||||
let role: string | undefined;
|
||||
try {
|
||||
role = decodeJwt(token).role as string | undefined;
|
||||
} catch {
|
||||
role = undefined;
|
||||
}
|
||||
if (role !== 'ADMIN') {
|
||||
return NextResponse.redirect(new URL('/support/dashboard', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/support/:path*', '/admin/:path*'],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactNode } from 'react';
|
||||
import { queryClient } from '@/lib/query/query-client';
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useContext } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCurrentSession } from '@/lib/api/auth';
|
||||
import { getSessionToken } from '@/lib/auth/session-cookie';
|
||||
import { Session } from '@/lib/api/types';
|
||||
|
||||
interface SessionContextValue {
|
||||
session: Session | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue>({ session: undefined, isLoading: false });
|
||||
|
||||
/** Re-validates against live account state (010's own FR-007) on mount, never trusting a
|
||||
* locally-decoded JWT claim for anything beyond middleware's own UX-only redirect (research.md).
|
||||
* Only queries when a session cookie exists at all — an unauthenticated visit to a public page
|
||||
* (e.g. /sign-in) must not call GET /auth/me and trip the 401 interceptor for no reason. */
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['session'],
|
||||
queryFn: getCurrentSession,
|
||||
enabled: !!getSessionToken(),
|
||||
});
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ session: query.data, isLoading: query.isLoading }}>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
return useContext(SessionContext);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Constitution Principle VII journey (B): an agent signing in and (eventually) working an
|
||||
* escalated ticket through to resolution. specs/001-agent-admin-ui/quickstart.md Scenario 0 —
|
||||
* User Story 2's own steps extend this spec once that story is built (tasks.md T027).
|
||||
*/
|
||||
test.describe('Agent sign-in and portal guard (User Story 0)', () => {
|
||||
test('redirects to sign-in when no session exists', async ({ page }) => {
|
||||
await page.goto('/support/dashboard');
|
||||
await expect(page).toHaveURL(/\/sign-in/);
|
||||
});
|
||||
|
||||
test('signs in with the seeded admin and lands on the admin dashboard', async ({ page }) => {
|
||||
await page.goto('/sign-in');
|
||||
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
|
||||
await page.getByLabel(/Password/).fill('ChangeMe123!');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/dashboard/);
|
||||
await expect(page.getByText('System Admin · ADMIN')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a wrong password shows one generic error', async ({ page }) => {
|
||||
await page.goto('/sign-in');
|
||||
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
|
||||
await page.getByLabel(/Password/).fill('wrong-password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page.getByText('Invalid email or password.')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/sign-in/);
|
||||
});
|
||||
|
||||
test('a non-admin session is redirected away from the admin portal', async ({ page }) => {
|
||||
await page.goto('/sign-in');
|
||||
await page.getByLabel(/Email/).fill('agent@supporthub.internal');
|
||||
await page.getByLabel(/Password/).fill('ChangeMe123!');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL(/\/support\/dashboard/);
|
||||
|
||||
await page.goto('/admin/teams');
|
||||
await expect(page).toHaveURL(/\/support\/dashboard/);
|
||||
});
|
||||
|
||||
test('signing out clears the session and redirects to sign-in', async ({ page }) => {
|
||||
await page.goto('/sign-in');
|
||||
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
|
||||
await page.getByLabel(/Password/).fill('ChangeMe123!');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/dashboard/);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign out' }).click();
|
||||
await expect(page).toHaveURL(/\/sign-in/);
|
||||
|
||||
await page.goto('/support/dashboard');
|
||||
await expect(page).toHaveURL(/\/sign-in/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SignInForm } from '@/features/auth/sign-in-form';
|
||||
import { ApiError } from '@/lib/api/types';
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
}));
|
||||
|
||||
import { login } from '@/lib/api/auth';
|
||||
|
||||
function renderWithQueryClient(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('SignInForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(login).mockReset();
|
||||
pushMock.mockReset();
|
||||
});
|
||||
|
||||
it('US0 scenario 2: on success, redirects to the role-appropriate landing page', async () => {
|
||||
vi.mocked(login).mockResolvedValue({
|
||||
token: 'fake-token',
|
||||
user: { id: 'u1', email: 'admin@supporthub.internal', name: 'Admin', role: 'ADMIN' },
|
||||
});
|
||||
renderWithQueryClient(<SignInForm />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Email/), 'admin@supporthub.internal');
|
||||
await userEvent.type(screen.getByLabelText(/Password/), 'ChangeMe123!');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => expect(pushMock).toHaveBeenCalledWith('/admin/dashboard'));
|
||||
});
|
||||
|
||||
it('US0 scenario 3: a login failure shows one generic message, the backend\'s own', async () => {
|
||||
vi.mocked(login).mockRejectedValue(new ApiError('Invalid email or password.', 'UNAUTHORIZED', 401));
|
||||
renderWithQueryClient(<SignInForm />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/Email/), 'admin@supporthub.internal');
|
||||
await userEvent.type(screen.getByLabelText(/Password/), 'wrong-password');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
expect(await screen.findByText('Invalid email or password.')).toBeInTheDocument();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getQueryState } from '@/lib/query/query-state';
|
||||
|
||||
describe('getQueryState', () => {
|
||||
it('returns loading while the query is loading', () => {
|
||||
expect(getQueryState({ isLoading: true, isError: false, data: undefined })).toBe('loading');
|
||||
});
|
||||
|
||||
it('returns error when the query failed, even if stale data is present', () => {
|
||||
expect(getQueryState({ isLoading: false, isError: true, data: [1] })).toBe('error');
|
||||
});
|
||||
|
||||
it('returns empty for a successful query whose data is an empty array', () => {
|
||||
expect(getQueryState({ isLoading: false, isError: false, data: [] })).toBe('empty');
|
||||
});
|
||||
|
||||
it('returns ready for a successful query with non-empty data', () => {
|
||||
expect(getQueryState({ isLoading: false, isError: false, data: [1, 2] })).toBe('ready');
|
||||
});
|
||||
|
||||
it('supports a custom isEmpty predicate for non-array data', () => {
|
||||
const result = getQueryState(
|
||||
{ isLoading: false, isError: false, data: { items: [] } },
|
||||
(data) => data.items.length === 0,
|
||||
);
|
||||
expect(result).toBe('empty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
include: ['tests/unit/**/*.test.{ts,tsx}', 'tests/integration/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user