@folpe/loom 0.1.0 → 0.3.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 +82 -16
- package/data/agents/backend/AGENT.md +12 -0
- package/data/agents/database/AGENT.md +3 -0
- package/data/agents/frontend/AGENT.md +10 -0
- package/data/agents/marketing/AGENT.md +3 -0
- package/data/agents/orchestrator/AGENT.md +1 -15
- package/data/agents/security/AGENT.md +3 -0
- package/data/agents/tests/AGENT.md +2 -0
- package/data/agents/ux-ui/AGENT.md +5 -0
- package/data/presets/api-backend.yaml +37 -0
- package/data/presets/chrome-extension.yaml +36 -0
- package/data/presets/cli-tool.yaml +31 -0
- package/data/presets/e-commerce.yaml +49 -0
- package/data/presets/expo-mobile.yaml +41 -0
- package/data/presets/fullstack-auth.yaml +45 -0
- package/data/presets/landing-page.yaml +38 -0
- package/data/presets/mvp-lean.yaml +35 -0
- package/data/presets/saas-default.yaml +7 -11
- package/data/presets/saas-full.yaml +71 -0
- package/data/skills/api-design/SKILL.md +149 -0
- package/data/skills/auth-rbac/SKILL.md +179 -0
- package/data/skills/better-auth-patterns/SKILL.md +212 -0
- package/data/skills/chrome-extension-patterns/SKILL.md +105 -0
- package/data/skills/cli-development/SKILL.md +147 -0
- package/data/skills/drizzle-patterns/SKILL.md +166 -0
- package/data/skills/env-validation/SKILL.md +142 -0
- package/data/skills/form-validation/SKILL.md +169 -0
- package/data/skills/hero-copywriting/SKILL.md +12 -4
- package/data/skills/i18n-patterns/SKILL.md +176 -0
- package/data/skills/layered-architecture/SKILL.md +131 -0
- package/data/skills/nextjs-conventions/SKILL.md +46 -7
- package/data/skills/react-native-patterns/SKILL.md +87 -0
- package/data/skills/react-query-patterns/SKILL.md +193 -0
- package/data/skills/resend-email/SKILL.md +181 -0
- package/data/skills/seo-optimization/SKILL.md +106 -0
- package/data/skills/server-actions-patterns/SKILL.md +156 -0
- package/data/skills/shadcn-ui/SKILL.md +126 -0
- package/data/skills/stripe-integration/SKILL.md +96 -0
- package/data/skills/supabase-patterns/SKILL.md +110 -0
- package/data/skills/table-pagination/SKILL.md +224 -0
- package/data/skills/tailwind-patterns/SKILL.md +12 -2
- package/data/skills/testing-patterns/SKILL.md +203 -0
- package/data/skills/ui-ux-guidelines/SKILL.md +179 -0
- package/dist/index.js +254 -100
- package/package.json +2 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-patterns
|
|
3
|
+
description: "Supabase patterns for auth, database, RLS, storage, and real-time. Use when working with Supabase, writing RLS policies, implementing auth flows, or managing file storage."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Supabase Patterns
|
|
7
|
+
|
|
8
|
+
## Critical Rules
|
|
9
|
+
|
|
10
|
+
- **Enable RLS on every table — no exceptions**, even internal tables.
|
|
11
|
+
- **Always validate session server-side** — never trust client-side `getUser()` alone.
|
|
12
|
+
- **Use `@supabase/ssr`** — never `@supabase/auth-helpers-nextjs` (deprecated).
|
|
13
|
+
- **Handle errors explicitly** — never ignore the `error` return value.
|
|
14
|
+
- **Use `LIMIT`** on all queries — never fetch unbounded result sets.
|
|
15
|
+
- **RLS policies on storage buckets** — no exceptions.
|
|
16
|
+
|
|
17
|
+
## Authentication
|
|
18
|
+
|
|
19
|
+
- Use `@supabase/ssr` for Next.js server-side auth.
|
|
20
|
+
- Create two client helpers:
|
|
21
|
+
- `createClient()` in `src/lib/supabase/client.ts` for Client Components
|
|
22
|
+
- `createServerClient()` in `src/lib/supabase/server.ts` for Server Components / Actions
|
|
23
|
+
- Always validate session server-side before granting access.
|
|
24
|
+
- Use middleware (`middleware.ts`) to refresh the session on every request:
|
|
25
|
+
```ts
|
|
26
|
+
const { data: { user } } = await supabase.auth.getUser()
|
|
27
|
+
if (!user && protectedRoutes.includes(request.nextUrl.pathname)) {
|
|
28
|
+
return NextResponse.redirect(new URL('/login', request.url))
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
- Support multiple auth strategies: email/password, OAuth (Google, GitHub), magic link.
|
|
32
|
+
- Store user metadata in a separate `profiles` table linked by `auth.users.id`.
|
|
33
|
+
|
|
34
|
+
## Row Level Security (RLS)
|
|
35
|
+
|
|
36
|
+
- Write policies that reference `auth.uid()` directly:
|
|
37
|
+
```sql
|
|
38
|
+
CREATE POLICY "Users read own data"
|
|
39
|
+
ON profiles FOR SELECT
|
|
40
|
+
USING (auth.uid() = user_id);
|
|
41
|
+
```
|
|
42
|
+
- Avoid `security definer` functions unless absolutely necessary. Prefer RLS policies.
|
|
43
|
+
- Test policies explicitly: try accessing data as different users/roles.
|
|
44
|
+
- Use `auth.jwt() ->> 'role'` for role-based access if implementing RBAC.
|
|
45
|
+
|
|
46
|
+
## Database Schema
|
|
47
|
+
|
|
48
|
+
- Use `uuid` for primary keys, generated by `gen_random_uuid()`.
|
|
49
|
+
- Add `created_at` and `updated_at` timestamps to every table:
|
|
50
|
+
```sql
|
|
51
|
+
created_at timestamptz DEFAULT now() NOT NULL,
|
|
52
|
+
updated_at timestamptz DEFAULT now() NOT NULL
|
|
53
|
+
```
|
|
54
|
+
- Create an `updated_at` trigger using `moddatetime` extension.
|
|
55
|
+
- Use foreign key constraints for referential integrity.
|
|
56
|
+
- Add indexes on columns used in WHERE clauses and JOIN conditions.
|
|
57
|
+
|
|
58
|
+
## Query Patterns
|
|
59
|
+
|
|
60
|
+
- Use the Supabase client query builder for simple CRUD:
|
|
61
|
+
```ts
|
|
62
|
+
const { data, error } = await supabase
|
|
63
|
+
.from('posts')
|
|
64
|
+
.select('*, author:profiles(name, avatar_url)')
|
|
65
|
+
.eq('published', true)
|
|
66
|
+
.order('created_at', { ascending: false })
|
|
67
|
+
.limit(20)
|
|
68
|
+
```
|
|
69
|
+
- Handle errors explicitly — never ignore `error`:
|
|
70
|
+
```ts
|
|
71
|
+
if (error) throw new Error(error.message)
|
|
72
|
+
```
|
|
73
|
+
- Use `.single()` when expecting exactly one row. Use `.maybeSingle()` when the row may not exist.
|
|
74
|
+
- Use server-side Supabase client for mutations in Server Actions.
|
|
75
|
+
|
|
76
|
+
## Real-time
|
|
77
|
+
|
|
78
|
+
- Use Supabase Realtime only for features that genuinely need live updates (chat, notifications, collaboration).
|
|
79
|
+
- Subscribe in Client Components and clean up on unmount:
|
|
80
|
+
```ts
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const channel = supabase.channel('room').on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, handler).subscribe()
|
|
83
|
+
return () => { supabase.removeChannel(channel) }
|
|
84
|
+
}, [])
|
|
85
|
+
```
|
|
86
|
+
- Prefer polling or revalidation for data that changes infrequently.
|
|
87
|
+
|
|
88
|
+
## Storage
|
|
89
|
+
|
|
90
|
+
- Use Supabase Storage for file uploads (images, documents).
|
|
91
|
+
- Create separate buckets for public vs private files.
|
|
92
|
+
- Set RLS policies on storage buckets — **no exceptions**.
|
|
93
|
+
- Generate signed URLs for private files with short expiration times.
|
|
94
|
+
- File upload pattern:
|
|
95
|
+
```ts
|
|
96
|
+
const { data, error } = await supabase.storage
|
|
97
|
+
.from("avatars")
|
|
98
|
+
.upload(`${userId}/${fileName}`, file, {
|
|
99
|
+
cacheControl: "3600",
|
|
100
|
+
upsert: true,
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
- Get public URL: `supabase.storage.from("avatars").getPublicUrl(path)`.
|
|
104
|
+
|
|
105
|
+
## Performance
|
|
106
|
+
|
|
107
|
+
- Use connection pooling (Supavisor) in production — never connect directly at scale.
|
|
108
|
+
- Add `LIMIT` to all queries. Never fetch unbounded result sets.
|
|
109
|
+
- Use `select('column1, column2')` instead of `select('*')` for large tables.
|
|
110
|
+
- Create partial indexes for frequently filtered subsets of data.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: table-pagination
|
|
3
|
+
description: "Data table patterns with server-side pagination, toolbar, and responsive columns. Use when building data tables, implementing search and pagination, or creating admin list views."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Table & Pagination Patterns
|
|
7
|
+
|
|
8
|
+
## Critical Rules
|
|
9
|
+
|
|
10
|
+
- **Server-side pagination** — paginate in the database, not in the client.
|
|
11
|
+
- **URL-based state** — page, limit, search, sort in query params for shareable URLs.
|
|
12
|
+
- **Toolbar = search + counter + limit** — consistent UX across all tables.
|
|
13
|
+
- **Responsive columns** — hide columns with `hidden sm:table-cell` breakpoints.
|
|
14
|
+
- **Reset page on filter change** — always return to page 1 when search/filter changes.
|
|
15
|
+
- **Use `tabular-nums`** on numeric columns — for proper alignment.
|
|
16
|
+
|
|
17
|
+
## Table Architecture
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
DataTable (container)
|
|
21
|
+
├── Toolbar (search + filters + counter + limit selector)
|
|
22
|
+
├── Table (columns + rows)
|
|
23
|
+
└── Pagination (page navigation)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Server-Side Pagination
|
|
27
|
+
|
|
28
|
+
### URL-Based State
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// app/users/page.tsx
|
|
32
|
+
import { Suspense } from "react";
|
|
33
|
+
import { getUsersPaginated } from "@/facades/user.facade";
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
searchParams: Promise<{
|
|
37
|
+
page?: string;
|
|
38
|
+
limit?: string;
|
|
39
|
+
search?: string;
|
|
40
|
+
sort?: string;
|
|
41
|
+
order?: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default async function UsersPage({ searchParams }: Props) {
|
|
46
|
+
const params = await searchParams;
|
|
47
|
+
const page = Number(params.page) || 1;
|
|
48
|
+
const limit = Number(params.limit) || 20;
|
|
49
|
+
|
|
50
|
+
const { data, total } = await getUsersPaginated({
|
|
51
|
+
page,
|
|
52
|
+
limit,
|
|
53
|
+
search: params.search,
|
|
54
|
+
sort: params.sort,
|
|
55
|
+
order: params.order as "asc" | "desc",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<DataTable
|
|
60
|
+
columns={columns}
|
|
61
|
+
data={data}
|
|
62
|
+
total={total}
|
|
63
|
+
page={page}
|
|
64
|
+
limit={limit}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Backend Pagination
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// src/dal/user.dal.ts
|
|
74
|
+
import { db } from "@/lib/db";
|
|
75
|
+
import { users } from "@/schema";
|
|
76
|
+
import { sql, ilike, desc, asc } from "drizzle-orm";
|
|
77
|
+
|
|
78
|
+
interface PaginationParams {
|
|
79
|
+
page: number;
|
|
80
|
+
limit: number;
|
|
81
|
+
search?: string;
|
|
82
|
+
sort?: string;
|
|
83
|
+
order?: "asc" | "desc";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function findUsersPaginated(params: PaginationParams) {
|
|
87
|
+
const { page, limit, search, sort = "createdAt", order = "desc" } = params;
|
|
88
|
+
const offset = (page - 1) * limit;
|
|
89
|
+
|
|
90
|
+
const where = search ? ilike(users.name, `%${search}%`) : undefined;
|
|
91
|
+
const orderBy = order === "asc" ? asc(users[sort]) : desc(users[sort]);
|
|
92
|
+
|
|
93
|
+
const [data, [{ count }]] = await Promise.all([
|
|
94
|
+
db.select().from(users).where(where).orderBy(orderBy).limit(limit).offset(offset),
|
|
95
|
+
db.select({ count: sql<number>`count(*)` }).from(users).where(where),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
return { data, total: Number(count) };
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Table Toolbar
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
"use client";
|
|
106
|
+
|
|
107
|
+
export function DataTableToolbar({ table, total }: ToolbarProps) {
|
|
108
|
+
const searchParams = useSearchParams();
|
|
109
|
+
const router = useRouter();
|
|
110
|
+
const pathname = usePathname();
|
|
111
|
+
|
|
112
|
+
function updateParam(key: string, value: string) {
|
|
113
|
+
const params = new URLSearchParams(searchParams);
|
|
114
|
+
if (value) params.set(key, value);
|
|
115
|
+
else params.delete(key);
|
|
116
|
+
params.delete("page"); // reset to page 1 on filter change
|
|
117
|
+
router.push(`${pathname}?${params}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex items-center justify-between gap-2">
|
|
122
|
+
<div className="flex flex-1 items-center gap-2">
|
|
123
|
+
<Input
|
|
124
|
+
placeholder="Search..."
|
|
125
|
+
defaultValue={searchParams.get("search") ?? ""}
|
|
126
|
+
onChange={(e) => updateParam("search", e.target.value)}
|
|
127
|
+
className="h-8 w-[250px]"
|
|
128
|
+
/>
|
|
129
|
+
<span className="text-sm text-muted-foreground">
|
|
130
|
+
{total} result{total !== 1 ? "s" : ""}
|
|
131
|
+
</span>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
<Select
|
|
135
|
+
defaultValue={searchParams.get("limit") ?? "20"}
|
|
136
|
+
onValueChange={(v) => updateParam("limit", v)}
|
|
137
|
+
>
|
|
138
|
+
<SelectTrigger className="h-8 w-[80px]">
|
|
139
|
+
<SelectValue />
|
|
140
|
+
</SelectTrigger>
|
|
141
|
+
<SelectContent>
|
|
142
|
+
{[10, 20, 50, 100].map((n) => (
|
|
143
|
+
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
|
144
|
+
))}
|
|
145
|
+
</SelectContent>
|
|
146
|
+
</Select>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Responsive Columns
|
|
154
|
+
|
|
155
|
+
Hide columns by breakpoint to keep tables readable on mobile:
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
const columns: ColumnDef<User>[] = [
|
|
159
|
+
{
|
|
160
|
+
accessorKey: "name",
|
|
161
|
+
header: "Name",
|
|
162
|
+
// Always visible
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
accessorKey: "email",
|
|
166
|
+
header: "Email",
|
|
167
|
+
meta: { className: "hidden sm:table-cell" },
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
accessorKey: "role",
|
|
171
|
+
header: "Role",
|
|
172
|
+
meta: { className: "hidden md:table-cell" },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
accessorKey: "createdAt",
|
|
176
|
+
header: "Created",
|
|
177
|
+
meta: { className: "hidden lg:table-cell" },
|
|
178
|
+
cell: ({ row }) => formatDate(row.getValue("createdAt")),
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Apply the responsive class in the table cell:
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
<TableCell className={column.columnDef.meta?.className}>
|
|
187
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
188
|
+
</TableCell>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Pagination Component
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
"use client";
|
|
195
|
+
|
|
196
|
+
export function DataTablePagination({ page, limit, total }: PaginationProps) {
|
|
197
|
+
const totalPages = Math.ceil(total / limit);
|
|
198
|
+
const searchParams = useSearchParams();
|
|
199
|
+
const router = useRouter();
|
|
200
|
+
const pathname = usePathname();
|
|
201
|
+
|
|
202
|
+
function goToPage(p: number) {
|
|
203
|
+
const params = new URLSearchParams(searchParams);
|
|
204
|
+
params.set("page", String(p));
|
|
205
|
+
router.push(`${pathname}?${params}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="flex items-center justify-between px-2">
|
|
210
|
+
<p className="text-sm text-muted-foreground">
|
|
211
|
+
Page {page} of {totalPages}
|
|
212
|
+
</p>
|
|
213
|
+
<div className="flex items-center gap-2">
|
|
214
|
+
<Button variant="outline" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 1}>
|
|
215
|
+
Previous
|
|
216
|
+
</Button>
|
|
217
|
+
<Button variant="outline" size="sm" onClick={() => goToPage(page + 1)} disabled={page >= totalPages}>
|
|
218
|
+
Next
|
|
219
|
+
</Button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
```
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tailwind-patterns
|
|
3
|
-
description: "
|
|
4
|
-
allowed-tools: "Read, Write, Edit"
|
|
3
|
+
description: "Tailwind CSS utility conventions, responsive design, and component styling. Use when styling components, implementing responsive layouts, or applying consistent spacing and typography."
|
|
5
4
|
---
|
|
6
5
|
|
|
7
6
|
# Tailwind CSS Patterns
|
|
8
7
|
|
|
8
|
+
## Critical Rules
|
|
9
|
+
|
|
10
|
+
- **Utility-first** — use utility classes directly in JSX, avoid `@apply`.
|
|
11
|
+
- **Mobile-first** — base styles for mobile, then `sm:`, `md:`, `lg:`, `xl:`.
|
|
12
|
+
- **Semantic color tokens** — use `bg-background`, `text-foreground`, never hardcode hex.
|
|
13
|
+
- **Progressive padding** — `px-0 sm:px-2 md:px-4 lg:px-6` grows with viewport.
|
|
14
|
+
- **Use `cn()`** — from `@/lib/utils` for conditional and merged classes.
|
|
15
|
+
|
|
9
16
|
## Utility-First Approach
|
|
10
17
|
|
|
11
18
|
- Always use utility classes directly in JSX. Avoid `@apply` except in global base styles.
|
|
@@ -30,6 +37,9 @@ allowed-tools: "Read, Write, Edit"
|
|
|
30
37
|
- `lg:` (1024px) — laptops
|
|
31
38
|
- `xl:` (1280px) — desktops
|
|
32
39
|
- Grid responsive pattern: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3`
|
|
40
|
+
- **Progressive padding**: `px-0 sm:px-2 md:px-4 lg:px-6` — spacing grows with viewport.
|
|
41
|
+
- **Cards border adaptatif**: `border-0 sm:border` — no border on mobile, border on larger screens.
|
|
42
|
+
- **Table columns by breakpoint**: hide non-essential columns on small screens with `hidden sm:table-cell`, `hidden md:table-cell`, `hidden lg:table-cell`.
|
|
33
43
|
|
|
34
44
|
## Typography
|
|
35
45
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing-patterns
|
|
3
|
+
description: "Vitest testing strategy with role-based patterns, seed data factories, and CI/CD integration. Use when writing tests, creating test data, or setting up continuous integration pipelines."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing Patterns
|
|
7
|
+
|
|
8
|
+
## Critical Rules
|
|
9
|
+
|
|
10
|
+
- **Test every role** — PUBLIC, USER, ADMIN for every feature.
|
|
11
|
+
- **Test services, not routes** — service layer is the source of truth.
|
|
12
|
+
- **Use seed factories** — deterministic, reusable test data.
|
|
13
|
+
- **Clean up after each test** — truncate tables, no test pollution.
|
|
14
|
+
- **CI runs tests + build** — every PR must pass before merge.
|
|
15
|
+
- **No mocking the database** — test against a real PostgreSQL instance.
|
|
16
|
+
|
|
17
|
+
## Role-Based Test Matrix
|
|
18
|
+
|
|
19
|
+
Every feature must be tested from 3 perspectives:
|
|
20
|
+
|
|
21
|
+
| Role | What to test |
|
|
22
|
+
|------|-------------|
|
|
23
|
+
| **PUBLIC** | Unauthenticated access — redirects, public pages, API 401s |
|
|
24
|
+
| **USER** | Standard user — CRUD own resources, forbidden on others |
|
|
25
|
+
| **ADMIN** | Admin user — manage all resources, admin-only features |
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
describe("deletePost", () => {
|
|
29
|
+
it("should return 401 for public (unauthenticated)", async () => {
|
|
30
|
+
const result = await deletePost(null, postId);
|
|
31
|
+
expect(result.error).toBe("Unauthorized");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should allow USER to delete own post", async () => {
|
|
35
|
+
const result = await deletePost(userSession, ownPostId);
|
|
36
|
+
expect(result.success).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should forbid USER from deleting another's post", async () => {
|
|
40
|
+
const result = await deletePost(userSession, otherPostId);
|
|
41
|
+
expect(result.error).toBe("Forbidden");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should allow ADMIN to delete any post", async () => {
|
|
45
|
+
const result = await deletePost(adminSession, otherPostId);
|
|
46
|
+
expect(result.success).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Vitest Configuration
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// vitest.config.ts
|
|
55
|
+
import { defineConfig } from "vitest/config";
|
|
56
|
+
import path from "path";
|
|
57
|
+
|
|
58
|
+
export default defineConfig({
|
|
59
|
+
test: {
|
|
60
|
+
globals: true,
|
|
61
|
+
environment: "node",
|
|
62
|
+
setupFiles: ["./tests/setup.ts"],
|
|
63
|
+
include: ["**/*.test.ts", "**/*.test.tsx"],
|
|
64
|
+
coverage: {
|
|
65
|
+
provider: "v8",
|
|
66
|
+
reporter: ["text", "lcov"],
|
|
67
|
+
exclude: ["node_modules", "tests/setup.ts"],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
resolve: {
|
|
71
|
+
alias: {
|
|
72
|
+
"@": path.resolve(__dirname, "./src"),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Seed Data
|
|
79
|
+
|
|
80
|
+
### Seed Users
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// tests/seed/users.ts
|
|
84
|
+
export const SEED_USERS = {
|
|
85
|
+
public: null, // no session
|
|
86
|
+
user: {
|
|
87
|
+
id: "user-1",
|
|
88
|
+
email: "user@test.com",
|
|
89
|
+
name: "Test User",
|
|
90
|
+
role: "USER" as const,
|
|
91
|
+
},
|
|
92
|
+
admin: {
|
|
93
|
+
id: "admin-1",
|
|
94
|
+
email: "admin@test.com",
|
|
95
|
+
name: "Test Admin",
|
|
96
|
+
role: "ADMIN" as const,
|
|
97
|
+
},
|
|
98
|
+
} as const;
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Seed Data Factory
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// tests/seed/factories.ts
|
|
105
|
+
import { db } from "@/lib/db";
|
|
106
|
+
import { users, posts } from "@/schema";
|
|
107
|
+
|
|
108
|
+
export async function seedUser(overrides?: Partial<NewUser>) {
|
|
109
|
+
const [user] = await db.insert(users).values({
|
|
110
|
+
name: "Test User",
|
|
111
|
+
email: `test-${Date.now()}@test.com`,
|
|
112
|
+
role: "USER",
|
|
113
|
+
...overrides,
|
|
114
|
+
}).returning();
|
|
115
|
+
return user;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function seedPost(authorId: string, overrides?: Partial<NewPost>) {
|
|
119
|
+
const [post] = await db.insert(posts).values({
|
|
120
|
+
title: "Test Post",
|
|
121
|
+
content: "Test content",
|
|
122
|
+
authorId,
|
|
123
|
+
published: true,
|
|
124
|
+
...overrides,
|
|
125
|
+
}).returning();
|
|
126
|
+
return post;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Test Setup
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// tests/setup.ts
|
|
134
|
+
import { beforeAll, afterAll, afterEach } from "vitest";
|
|
135
|
+
import { db } from "@/lib/db";
|
|
136
|
+
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
await migrate(db, { migrationsFolder: "./drizzle" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(async () => {
|
|
143
|
+
// Clean up test data — truncate in reverse FK order
|
|
144
|
+
await db.execute(sql`TRUNCATE posts, users CASCADE`);
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Service Layer Testing
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// src/services/__tests__/user.service.test.ts
|
|
152
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
153
|
+
import { listUsers, deleteUser } from "@/services/user.service";
|
|
154
|
+
import { SEED_USERS } from "tests/seed/users";
|
|
155
|
+
import { seedUser } from "tests/seed/factories";
|
|
156
|
+
|
|
157
|
+
describe("user.service", () => {
|
|
158
|
+
let testUser: User;
|
|
159
|
+
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
testUser = await seedUser();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("listUsers", () => {
|
|
165
|
+
it("should allow ADMIN to list all users", async () => {
|
|
166
|
+
const result = await listUsers(SEED_USERS.admin);
|
|
167
|
+
expect(result).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should forbid USER from listing users", async () => {
|
|
171
|
+
await expect(listUsers(SEED_USERS.user)).rejects.toThrow("Forbidden");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## CI/CD Integration
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
# .github/workflows/ci.yml
|
|
181
|
+
name: CI
|
|
182
|
+
on: [push, pull_request]
|
|
183
|
+
|
|
184
|
+
jobs:
|
|
185
|
+
test:
|
|
186
|
+
runs-on: ubuntu-latest
|
|
187
|
+
services:
|
|
188
|
+
postgres:
|
|
189
|
+
image: postgres:16
|
|
190
|
+
env:
|
|
191
|
+
POSTGRES_DB: test
|
|
192
|
+
POSTGRES_USER: test
|
|
193
|
+
POSTGRES_PASSWORD: test
|
|
194
|
+
ports: ["5432:5432"]
|
|
195
|
+
steps:
|
|
196
|
+
- uses: actions/checkout@v4
|
|
197
|
+
- uses: pnpm/action-setup@v4
|
|
198
|
+
- uses: actions/setup-node@v4
|
|
199
|
+
with: { node-version: 20, cache: "pnpm" }
|
|
200
|
+
- run: pnpm install --frozen-lockfile
|
|
201
|
+
- run: pnpm test
|
|
202
|
+
- run: pnpm build
|
|
203
|
+
```
|