@dennisrongo/skills 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -13
- package/package.json +1 -1
- package/skills/nextjs-app-router/SKILL.md +228 -175
- package/skills/nextjs-app-router/references/anti-patterns.md +163 -99
- package/skills/nextjs-app-router/references/folder-layout.md +79 -46
- package/skills/nextjs-app-router/references/good-patterns.md +233 -99
- package/skills/nextjs-app-router/references/templates/api-base.md +24 -21
- package/skills/nextjs-app-router/references/templates/auth-slice.md +82 -78
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +58 -6
- package/skills/nextjs-app-router/references/templates/db-client.md +48 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +90 -23
- package/skills/nextjs-app-router/references/templates/feature-slice.md +110 -47
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +23 -15
- package/skills/nextjs-app-router/references/templates/middleware.md +69 -59
- package/skills/nextjs-app-router/references/templates/next-config.md +4 -14
- package/skills/nextjs-app-router/references/templates/nextauth-config.md +178 -0
- package/skills/nextjs-app-router/references/templates/package.md +35 -5
- package/skills/nextjs-app-router/references/templates/prisma-schema.md +162 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +35 -21
- package/skills/nextjs-app-router/references/templates/root-layout.md +99 -20
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +46 -6
- package/skills/nextjs-app-router/references/templates/route-handler.md +168 -0
- package/skills/nextjs-app-router/references/templates/testing.md +105 -31
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Route group layouts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Group layouts are **server components** that render zero data — they just delegate to a `'use client'` shell. Pages inside the group are `'use client'`.
|
|
4
4
|
|
|
5
|
-
## `src/app/(public)/layout.tsx` (SERVER)
|
|
5
|
+
## `src/app/(public)/layout.tsx` (SERVER, zero data)
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
8
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
|
@@ -20,12 +20,14 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|
|
20
20
|
import { AppShell } from './_components/AppShell';
|
|
21
21
|
|
|
22
22
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|
23
|
-
// Server-side session check
|
|
24
|
-
//
|
|
23
|
+
// Server-side session check is done in middleware.ts — by the time we render here,
|
|
24
|
+
// the user is authenticated. We do NOT call `await auth()` here; the gate is the middleware.
|
|
25
25
|
return <AppShell>{children}</AppShell>;
|
|
26
26
|
}
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
**No `await auth()` here.** The middleware already gated the request. Calling `auth()` again from this layout adds a round-trip and doesn't change the outcome.
|
|
30
|
+
|
|
29
31
|
## `src/app/(app)/_components/AppShell.tsx` (CLIENT, holds interactive UI state)
|
|
30
32
|
|
|
31
33
|
```tsx
|
|
@@ -50,9 +52,40 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|
|
50
52
|
}
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
## `src/app/(app)/
|
|
55
|
+
## `src/app/(app)/_components/UserMenu.tsx` (CLIENT — uses NextAuth)
|
|
54
56
|
|
|
55
57
|
```tsx
|
|
58
|
+
'use client';
|
|
59
|
+
|
|
60
|
+
import { signOut, useSession } from 'next-auth/react';
|
|
61
|
+
import { Button } from '@/components/ui/button';
|
|
62
|
+
|
|
63
|
+
export function UserMenu() {
|
|
64
|
+
const { data: session, status } = useSession();
|
|
65
|
+
|
|
66
|
+
if (status === 'loading') return null;
|
|
67
|
+
if (!session?.user) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex items-center gap-3">
|
|
71
|
+
<span className="text-sm text-muted-foreground">{session.user.email}</span>
|
|
72
|
+
<Button
|
|
73
|
+
variant="ghost"
|
|
74
|
+
size="sm"
|
|
75
|
+
onClick={() => signOut({ callbackUrl: '/auth/login', redirect: true })}
|
|
76
|
+
>
|
|
77
|
+
Sign out
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## `src/app/(app)/loading.tsx` (CLIENT — Suspense fallback)
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
'use client';
|
|
88
|
+
|
|
56
89
|
export default function Loading() {
|
|
57
90
|
return (
|
|
58
91
|
<div className="flex h-[50vh] items-center justify-center">
|
|
@@ -87,4 +120,11 @@ export default function AppError({ error, reset }: { error: Error & { digest?: s
|
|
|
87
120
|
|
|
88
121
|
## `src/app/(admin)/layout.tsx` (OPTIONAL, SERVER)
|
|
89
122
|
|
|
90
|
-
Same shape as `(app)/layout.tsx`. The role check happens in `middleware.ts
|
|
123
|
+
Same shape as `(app)/layout.tsx`. The role check happens in `middleware.ts` (Variant B in [`middleware.md`](middleware.md)); this layout just renders an admin-flavored shell.
|
|
124
|
+
|
|
125
|
+
## Forbidden in any layout
|
|
126
|
+
|
|
127
|
+
- `await auth()` — duplicates middleware's job.
|
|
128
|
+
- `await db.*` — server data fetching is banned.
|
|
129
|
+
- `'use client'` directly on a `layout.tsx` — push interactive chrome into a client child.
|
|
130
|
+
- `redirect()` — middleware handles all redirects.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Route Handler template
|
|
2
|
+
|
|
3
|
+
Route Handlers under `src/app/api/**/route.ts` are the backend. Every one (except `/api/auth/[...nextauth]` and explicit public endpoints like `/api/health`) authenticates with `requireSession()`, validates inputs with the feature's Zod schema, and accesses the DB through the Prisma singleton.
|
|
4
|
+
|
|
5
|
+
## `src/app/api/{{feature}}s/route.ts` — list + create
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { db } from '@/lib/db';
|
|
10
|
+
import { requireSession, withApiErrors } from '@/lib/api-auth';
|
|
11
|
+
import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
|
|
12
|
+
|
|
13
|
+
export const GET = withApiErrors(async () => {
|
|
14
|
+
const session = await requireSession();
|
|
15
|
+
|
|
16
|
+
const rows = await db.{{feature}}.findMany({
|
|
17
|
+
where: { userId: session.user.id },
|
|
18
|
+
orderBy: { createdAt: 'desc' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return NextResponse.json(rows);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const POST = withApiErrors(async (req: Request) => {
|
|
25
|
+
const session = await requireSession();
|
|
26
|
+
|
|
27
|
+
const body = await req.json().catch(() => null);
|
|
28
|
+
const parsed = {{feature}}Schema.safeParse(body);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const created = await db.{{feature}}.create({
|
|
34
|
+
data: {
|
|
35
|
+
...parsed.data,
|
|
36
|
+
userId: session.user.id,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return NextResponse.json(created, { status: 201 });
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## `src/app/api/{{feature}}s/[id]/route.ts` — get + update + delete
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { NextResponse } from 'next/server';
|
|
48
|
+
import { db } from '@/lib/db';
|
|
49
|
+
import { requireSession, withApiErrors, assertOwnership } from '@/lib/api-auth';
|
|
50
|
+
import { {{feature}}Schema } from '@/app/(app)/{{feature}}s/schema';
|
|
51
|
+
|
|
52
|
+
interface RouteContext {
|
|
53
|
+
params: Promise<{ id: string }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const GET = withApiErrors(async (_req: Request, ctx: RouteContext) => {
|
|
57
|
+
const session = await requireSession();
|
|
58
|
+
const { id } = await ctx.params;
|
|
59
|
+
|
|
60
|
+
const row = await db.{{feature}}.findFirst({
|
|
61
|
+
where: { id, userId: session.user.id },
|
|
62
|
+
});
|
|
63
|
+
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
64
|
+
|
|
65
|
+
return NextResponse.json(row);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const PATCH = withApiErrors(async (req: Request, ctx: RouteContext) => {
|
|
69
|
+
const session = await requireSession();
|
|
70
|
+
const { id } = await ctx.params;
|
|
71
|
+
|
|
72
|
+
const body = await req.json().catch(() => null);
|
|
73
|
+
const parsed = {{feature}}Schema.partial().safeParse(body);
|
|
74
|
+
if (!parsed.success) {
|
|
75
|
+
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Ownership check via findFirst + userId predicate.
|
|
79
|
+
const existing = await db.{{feature}}.findFirst({
|
|
80
|
+
where: { id, userId: session.user.id },
|
|
81
|
+
select: { id: true },
|
|
82
|
+
});
|
|
83
|
+
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
84
|
+
|
|
85
|
+
const updated = await db.{{feature}}.update({
|
|
86
|
+
where: { id },
|
|
87
|
+
data: parsed.data,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return NextResponse.json(updated);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const DELETE = withApiErrors(async (_req: Request, ctx: RouteContext) => {
|
|
94
|
+
const session = await requireSession();
|
|
95
|
+
const { id } = await ctx.params;
|
|
96
|
+
|
|
97
|
+
const existing = await db.{{feature}}.findFirst({
|
|
98
|
+
where: { id, userId: session.user.id },
|
|
99
|
+
select: { id: true },
|
|
100
|
+
});
|
|
101
|
+
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
102
|
+
|
|
103
|
+
await db.{{feature}}.delete({ where: { id } });
|
|
104
|
+
|
|
105
|
+
return new NextResponse(null, { status: 204 });
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Public handlers (e.g. health check)
|
|
110
|
+
|
|
111
|
+
The matcher in `middleware.ts` already excludes `/api/auth/*`. Other `/api/*` routes aren't redirected by middleware (they run their own `requireSession()`). A public handler simply omits the session check:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// src/app/api/health/route.ts
|
|
115
|
+
import { NextResponse } from 'next/server';
|
|
116
|
+
export const GET = () => NextResponse.json({ ok: true });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Comment in the file so the absence of `requireSession()` is intentional, not an oversight.
|
|
120
|
+
|
|
121
|
+
## Forbidden
|
|
122
|
+
|
|
123
|
+
- **No `requireSession()` call** in any handler outside `/api/auth/*` and explicitly-public ones (and the public ones must comment why).
|
|
124
|
+
- **Trusting `userId` from the request body.** Always read it from the session: `session.user.id`.
|
|
125
|
+
- **Returning the full record on POST/PATCH without checking ownership first** on updates — the `where: { id, userId }` predicate is the gate.
|
|
126
|
+
- **`prisma.$queryRaw` with string concatenation.** Use parameterized template literals (`prisma.$queryRaw\`... ${value} ...\``) or stick to the typed query builder.
|
|
127
|
+
- **Catching and re-throwing without logging.** `withApiErrors` logs unexpected errors; if you write your own try/catch, log unexpected ones at least once.
|
|
128
|
+
- **Returning 500 with the raw error message.** Leaks stack traces. `withApiErrors` returns a generic message; if you write your own, do the same.
|
|
129
|
+
- **Long-running work in a handler.** Route Handlers run per-request. For background work, dispatch to a queue (the skill doesn't ship one — ask the user to pick).
|
|
130
|
+
- **`Set-Cookie` headers writing your own session cookie.** NextAuth owns the session cookie; setting a parallel one is the "custom JWT alongside NextAuth" anti-pattern.
|
|
131
|
+
|
|
132
|
+
## Why we import the schema from the feature folder
|
|
133
|
+
|
|
134
|
+
The form (`_components/{{Feature}}Form.tsx`) and the handler validate the same shape. If the handler had its own schema, drift would silently let the form post payloads the handler accepts but the form's UI hasn't accounted for — or vice versa. One Zod schema in `(app)/{{feature}}s/schema.ts` is the contract; both sides import it.
|
|
135
|
+
|
|
136
|
+
## Handler test (optional but recommended)
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// tests/unit/{{feature}}.handler.test.ts
|
|
140
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
141
|
+
|
|
142
|
+
vi.mock('@/auth', () => ({ auth: vi.fn() }));
|
|
143
|
+
vi.mock('@/lib/db', () => ({
|
|
144
|
+
db: { {{feature}}: { findMany: vi.fn(), create: vi.fn() } },
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
import { GET, POST } from '@/app/api/{{feature}}s/route';
|
|
148
|
+
import { auth } from '@/auth';
|
|
149
|
+
import { db } from '@/lib/db';
|
|
150
|
+
|
|
151
|
+
describe('GET /api/{{feature}}s', () => {
|
|
152
|
+
beforeEach(() => vi.clearAllMocks());
|
|
153
|
+
|
|
154
|
+
it('401s when unauthenticated', async () => {
|
|
155
|
+
(auth as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
|
156
|
+
const res = await GET();
|
|
157
|
+
expect(res.status).toBe(401);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns the current user's rows', async () => {
|
|
161
|
+
(auth as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
162
|
+
(db.{{feature}}.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([{ id: 'r1', userId: 'u1' }]);
|
|
163
|
+
const res = await GET();
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
expect(await res.json()).toEqual([{ id: 'r1', userId: 'u1' }]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Testing setup (Vitest + RTL + Playwright)
|
|
2
2
|
|
|
3
|
-
A new project ships with
|
|
3
|
+
A new project ships with a small set of real tests: a Zod schema test, a Route Handler test, a component test, and an E2E auth spec. Just enough to make CI meaningful.
|
|
4
4
|
|
|
5
5
|
## `vitest.config.ts`
|
|
6
6
|
|
|
@@ -38,61 +38,123 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
## `tests/unit/
|
|
41
|
+
## `tests/unit/customer.schema.test.ts`
|
|
42
42
|
|
|
43
43
|
```ts
|
|
44
44
|
import { describe, expect, it } from 'vitest';
|
|
45
|
-
import {
|
|
45
|
+
import { customerSchema } from '@/app/(app)/customers/schema';
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
describe('customerSchema', () => {
|
|
48
|
+
it('accepts a valid payload', () => {
|
|
49
|
+
expect(customerSchema.safeParse({ name: 'Acme', email: 'ops@acme.test' }).success).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects an empty name', () => {
|
|
53
|
+
expect(customerSchema.safeParse({ name: '', email: 'ops@acme.test' }).success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects an invalid email', () => {
|
|
57
|
+
expect(customerSchema.safeParse({ name: 'Acme', email: 'nope' }).success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This is the test that pays for itself most often. The same schema validates form input *and* the Route Handler's request body, so regressions on either side fail here.
|
|
63
|
+
|
|
64
|
+
## `tests/unit/customers.handler.test.ts` (Route Handler test with mocked `auth` + `db`)
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
68
|
+
|
|
69
|
+
vi.mock('@/auth', () => ({ auth: vi.fn() }));
|
|
70
|
+
vi.mock('@/lib/db', () => ({
|
|
71
|
+
db: {
|
|
72
|
+
customer: {
|
|
73
|
+
findMany: vi.fn(),
|
|
74
|
+
create: vi.fn(),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
48
78
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
import { GET, POST } from '@/app/api/customers/route';
|
|
80
|
+
import { auth } from '@/auth';
|
|
81
|
+
import { db } from '@/lib/db';
|
|
82
|
+
|
|
83
|
+
const mockAuth = auth as ReturnType<typeof vi.fn>;
|
|
84
|
+
const mockFindMany = db.customer.findMany as ReturnType<typeof vi.fn>;
|
|
85
|
+
const mockCreate = db.customer.create as ReturnType<typeof vi.fn>;
|
|
86
|
+
|
|
87
|
+
describe('GET /api/customers', () => {
|
|
88
|
+
beforeEach(() => vi.clearAllMocks());
|
|
89
|
+
|
|
90
|
+
it('401s when unauthenticated', async () => {
|
|
91
|
+
mockAuth.mockResolvedValueOnce(null);
|
|
92
|
+
const res = await GET();
|
|
93
|
+
expect(res.status).toBe(401);
|
|
53
94
|
});
|
|
54
95
|
|
|
55
|
-
it('
|
|
56
|
-
|
|
57
|
-
|
|
96
|
+
it('returns the current user\'s customers', async () => {
|
|
97
|
+
mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
98
|
+
mockFindMany.mockResolvedValueOnce([{ id: 'c1', userId: 'u1', name: 'Acme' }]);
|
|
99
|
+
const res = await GET();
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(await res.json()).toEqual([{ id: 'c1', userId: 'u1', name: 'Acme' }]);
|
|
58
102
|
});
|
|
103
|
+
});
|
|
59
104
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
105
|
+
describe('POST /api/customers', () => {
|
|
106
|
+
beforeEach(() => vi.clearAllMocks());
|
|
107
|
+
|
|
108
|
+
it('400s on an invalid payload', async () => {
|
|
109
|
+
mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
110
|
+
const req = new Request('http://x/api/customers', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
body: JSON.stringify({ name: '', email: 'nope' }),
|
|
113
|
+
});
|
|
114
|
+
const res = await POST(req);
|
|
115
|
+
expect(res.status).toBe(400);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('attaches the session userId to the created row', async () => {
|
|
119
|
+
mockAuth.mockResolvedValueOnce({ user: { id: 'u1' } });
|
|
120
|
+
mockCreate.mockResolvedValueOnce({ id: 'c2', userId: 'u1', name: 'Acme', email: 'ops@acme.test' });
|
|
121
|
+
const req = new Request('http://x/api/customers', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: JSON.stringify({ name: 'Acme', email: 'ops@acme.test' }),
|
|
124
|
+
});
|
|
125
|
+
await POST(req);
|
|
126
|
+
expect(mockCreate).toHaveBeenCalledWith({
|
|
127
|
+
data: { name: 'Acme', email: 'ops@acme.test', userId: 'u1' },
|
|
128
|
+
});
|
|
63
129
|
});
|
|
64
130
|
});
|
|
65
131
|
```
|
|
66
132
|
|
|
133
|
+
This test catches three common regressions: missing `requireSession`, body validation that silently accepts garbage, and userId tampering.
|
|
134
|
+
|
|
67
135
|
## `tests/unit/login-form.test.tsx`
|
|
68
136
|
|
|
69
137
|
```tsx
|
|
70
|
-
import { describe, expect, it } from 'vitest';
|
|
138
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
71
139
|
import { render, screen } from '@testing-library/react';
|
|
72
140
|
import userEvent from '@testing-library/user-event';
|
|
73
|
-
import { Provider } from 'react-redux';
|
|
74
|
-
import { configureStore } from '@reduxjs/toolkit';
|
|
75
|
-
import { api } from '@/redux/api/api';
|
|
76
|
-
import { authSlice } from '@/redux/features/authSlice';
|
|
77
141
|
import { LoginForm } from '@/app/(public)/auth/login/_components/LoginForm';
|
|
78
142
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
middleware: (gdm) => gdm().concat(api.middleware),
|
|
83
|
-
});
|
|
84
|
-
return render(<Provider store={store}>{ui}</Provider>);
|
|
85
|
-
}
|
|
143
|
+
vi.mock('next-auth/react', () => ({
|
|
144
|
+
signIn: vi.fn().mockResolvedValue({ ok: true, error: null }),
|
|
145
|
+
}));
|
|
86
146
|
|
|
87
147
|
describe('<LoginForm />', () => {
|
|
88
|
-
it('shows a validation error
|
|
89
|
-
|
|
148
|
+
it('shows a validation error when fields are empty', async () => {
|
|
149
|
+
render(<LoginForm />);
|
|
90
150
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
|
91
151
|
expect(await screen.findByText(/required|invalid/i)).toBeInTheDocument();
|
|
92
152
|
});
|
|
93
153
|
});
|
|
94
154
|
```
|
|
95
155
|
|
|
156
|
+
Mocking `next-auth/react` lets the form test run in isolation — we're testing the form, not NextAuth.
|
|
157
|
+
|
|
96
158
|
## `playwright.config.ts`
|
|
97
159
|
|
|
98
160
|
```ts
|
|
@@ -124,14 +186,26 @@ export default defineConfig({
|
|
|
124
186
|
import { test, expect } from '@playwright/test';
|
|
125
187
|
|
|
126
188
|
test('unauthenticated user is redirected to login', async ({ page }) => {
|
|
127
|
-
await page.goto('/dashboard');
|
|
189
|
+
await page.goto('/app/dashboard');
|
|
128
190
|
await expect(page).toHaveURL(/\/auth\/login/);
|
|
129
191
|
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
|
130
192
|
});
|
|
193
|
+
|
|
194
|
+
// Requires `pnpm db:seed` to have run — provides test@example.com / test1234.
|
|
195
|
+
test('seeded user can sign in and land on the dashboard', async ({ page }) => {
|
|
196
|
+
await page.goto('/auth/login');
|
|
197
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
198
|
+
await page.getByLabel(/password/i).fill('test1234');
|
|
199
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
200
|
+
await expect(page).toHaveURL(/\/app\/dashboard/);
|
|
201
|
+
});
|
|
131
202
|
```
|
|
132
203
|
|
|
133
|
-
|
|
204
|
+
Together those two specs validate: middleware fires, the login page renders, NextAuth's Credentials provider works against the seeded user, and the route-group split holds.
|
|
134
205
|
|
|
135
206
|
**Notes:**
|
|
136
|
-
|
|
207
|
+
|
|
208
|
+
- Do not include Playwright in the default `pnpm test` script — keep it as `pnpm test:e2e`.
|
|
209
|
+
- The seed-then-login test requires the CI workflow's `pnpm db:seed` step (see [`ci-and-hooks.md`](ci-and-hooks.md)).
|
|
137
210
|
- Real backend mocking (MSW) is *not* in the default scaffold. Add only if the user asks.
|
|
211
|
+
- **Forbidden:** an `authSlice.test.ts`. There is no `authSlice` in this project — NextAuth owns session state. If you see a test importing `@/redux/features/authSlice`, delete it.
|