MCP Server 2.0 (#452)
* MCP Server 2.0 - Updated application version from 2.23.14 to 2.24.0 in package.json. - MCP Server improved with new features - Migrated functionality from Dev Tools to MCP Server - Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
committed by
GitHub
parent
059408a70a
commit
f3ac595d06
@@ -29,22 +29,24 @@ export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
|
||||
|
||||
### Step 2: Create Service Layer
|
||||
|
||||
**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, a CLI command, or a plain unit test.
|
||||
|
||||
Create service in `_lib/server/`:
|
||||
|
||||
```typescript
|
||||
// _lib/server/feature.service.ts
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { CreateFeatureInput } from '../schemas/feature.schema';
|
||||
|
||||
export function createFeatureService() {
|
||||
return new FeatureService();
|
||||
export function createFeatureService(client: SupabaseClient) {
|
||||
return new FeatureService(client);
|
||||
}
|
||||
|
||||
class FeatureService {
|
||||
async create(data: CreateFeatureInput) {
|
||||
const client = getSupabaseServerClient();
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
|
||||
const { data: result, error } = await client
|
||||
async create(data: CreateFeatureInput) {
|
||||
const { data: result, error } = await this.client
|
||||
.from('features')
|
||||
.insert({
|
||||
name: data.name,
|
||||
@@ -60,7 +62,11 @@ class FeatureService {
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Server Action
|
||||
The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (pass a mock client) and reusable (any interface can supply its own client).
|
||||
|
||||
### Step 3: Create Server Action (Thin Adapter)
|
||||
|
||||
The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business logic lives here.
|
||||
|
||||
Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
@@ -69,6 +75,7 @@ Create action in `_lib/server/server-actions.ts`:
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { CreateFeatureSchema } from '../schemas/feature.schema';
|
||||
@@ -81,7 +88,8 @@ export const createFeatureAction = enhanceAction(
|
||||
|
||||
logger.info(ctx, 'Creating feature');
|
||||
|
||||
const service = createFeatureService();
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createFeatureService(client);
|
||||
const result = await service.create(data);
|
||||
|
||||
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
|
||||
@@ -99,11 +107,13 @@ export const createFeatureAction = enhanceAction(
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Schema in separate file** - Reusable between client and server
|
||||
2. **Service layer** - Business logic isolated from action
|
||||
3. **Logging** - Always log before and after operations
|
||||
4. **Revalidation** - Use `revalidatePath` after mutations
|
||||
5. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server action do the same thing, they call the same service function.
|
||||
2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with stubs and reusable across interfaces.
|
||||
3. **Schema in separate file** - Reusable between client and server
|
||||
4. **Logging** - Always log before and after operations
|
||||
5. **Revalidation** - Use `revalidatePath` after mutations
|
||||
6. **Trust RLS** - Don't add manual auth checks (RLS handles it)
|
||||
7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no running infrastructure
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
308
.claude/skills/service-builder/SKILL.md
Normal file
308
.claude/skills/service-builder/SKILL.md
Normal file
@@ -0,0 +1,308 @@
|
||||
---
|
||||
name: service-builder
|
||||
description: Build pure, interface-agnostic services with injected dependencies. Use when creating business logic that must work across server actions, MCP tools, CLI commands, or tests. Invoke with /service-builder.
|
||||
---
|
||||
|
||||
# Service Builder
|
||||
|
||||
You are an expert at building pure, testable services that are decoupled from their callers.
|
||||
|
||||
## North Star
|
||||
|
||||
**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates.
|
||||
|
||||
## Workflow
|
||||
|
||||
When asked to create a service, follow these steps:
|
||||
|
||||
### Step 1: Define the Contract
|
||||
|
||||
Start with the input/output types. These are plain TypeScript — no framework types.
|
||||
|
||||
```typescript
|
||||
// _lib/schemas/project.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateProjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
account_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Build the Service
|
||||
|
||||
The service receives all dependencies through its constructor. It never imports framework-specific modules (`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.).
|
||||
|
||||
```typescript
|
||||
// _lib/server/project.service.ts
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateProjectInput, Project } from '../schemas/project.schema';
|
||||
|
||||
export function createProjectService(client: SupabaseClient) {
|
||||
return new ProjectService(client);
|
||||
}
|
||||
|
||||
class ProjectService {
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
|
||||
async create(data: CreateProjectInput): Promise<Project> {
|
||||
const { data: result, error } = await this.client
|
||||
.from('projects')
|
||||
.insert({
|
||||
name: data.name,
|
||||
account_id: data.accountId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async list(accountId: string): Promise<Project[]> {
|
||||
const { data, error } = await this.client
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async delete(projectId: string): Promise<void> {
|
||||
const { error } = await this.client
|
||||
.from('projects')
|
||||
.delete()
|
||||
.eq('id', projectId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Write Thin Adapters
|
||||
|
||||
Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output).
|
||||
|
||||
**Server Action adapter:**
|
||||
|
||||
```typescript
|
||||
// _lib/server/server-actions.ts
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { CreateProjectSchema } from '../schemas/project.schema';
|
||||
import { createProjectService } from './project.service';
|
||||
|
||||
export const createProjectAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: 'create-project', userId: user.id }, 'Creating project');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createProjectService(client);
|
||||
const result = await service.create(data);
|
||||
|
||||
revalidatePath('/home/[account]/projects');
|
||||
|
||||
return { success: true, data: result };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Route Handler adapter:**
|
||||
|
||||
```typescript
|
||||
// app/api/projects/route.ts
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { CreateProjectSchema } from '../_lib/schemas/project.schema';
|
||||
import { createProjectService } from '../_lib/server/project.service';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user }) {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createProjectService(client);
|
||||
const result = await service.create(body);
|
||||
|
||||
return NextResponse.json(result);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateProjectSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**MCP Tool adapter:**
|
||||
|
||||
```typescript
|
||||
// mcp/tools/kit_project_create.ts
|
||||
import { createProjectService } from '../../_lib/server/project.service';
|
||||
|
||||
export const kit_project_create: McpToolHandler = async (input, context) => {
|
||||
const client = context.getSupabaseClient();
|
||||
const service = createProjectService(client);
|
||||
|
||||
return service.create(input);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Write Tests
|
||||
|
||||
Because the service accepts dependencies, you can test it with stubs — no running database, no framework runtime.
|
||||
|
||||
```typescript
|
||||
// _lib/server/__tests__/project.service.test.ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { createProjectService } from '../project.service';
|
||||
|
||||
function createMockClient(overrides: Record<string, unknown> = {}) {
|
||||
const mockChain = {
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: { id: 'proj-1', name: 'Test', account_id: 'acc-1', created_at: new Date().toISOString() },
|
||||
error: null,
|
||||
}),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
from: vi.fn(() => mockChain),
|
||||
mockChain,
|
||||
} as unknown as SupabaseClient;
|
||||
}
|
||||
|
||||
describe('ProjectService', () => {
|
||||
it('creates a project', async () => {
|
||||
const client = createMockClient();
|
||||
const service = createProjectService(client);
|
||||
|
||||
const result = await service.create({
|
||||
name: 'Test Project',
|
||||
accountId: 'acc-1',
|
||||
});
|
||||
|
||||
expect(result.id).toBe('proj-1');
|
||||
expect(client.from).toHaveBeenCalledWith('projects');
|
||||
});
|
||||
|
||||
it('throws on database error', async () => {
|
||||
const client = createMockClient({
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'unique violation' },
|
||||
}),
|
||||
});
|
||||
|
||||
const service = createProjectService(client);
|
||||
|
||||
await expect(
|
||||
service.create({ name: 'Dup', accountId: 'acc-1' }),
|
||||
).rejects.toEqual({ message: 'unique violation' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`.
|
||||
|
||||
2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service.
|
||||
|
||||
3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters.
|
||||
|
||||
4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating logic is a violation.
|
||||
|
||||
5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, refactor until you don't.
|
||||
|
||||
## What Goes Where
|
||||
|
||||
| Concern | Location | Example |
|
||||
|---------|----------|---------|
|
||||
| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` |
|
||||
| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` |
|
||||
| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper |
|
||||
| Logging | Adapter | `logger.info()` before/after service call |
|
||||
| Cache revalidation | Adapter | `revalidatePath()` after mutation |
|
||||
| Redirect | Adapter | `redirect()` after creation |
|
||||
| MCP response format | Adapter | Return service result as MCP content |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
feature/
|
||||
├── _lib/
|
||||
│ ├── schemas/
|
||||
│ │ └── feature.schema.ts # Zod schemas + TS types
|
||||
│ └── server/
|
||||
│ ├── feature.service.ts # Pure service (dependencies injected)
|
||||
│ ├── server-actions.ts # Server action adapters
|
||||
│ └── __tests__/
|
||||
│ └── feature.service.test.ts # Unit tests with mock client
|
||||
└── _components/
|
||||
└── feature-form.tsx
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Service imports framework-specific client
|
||||
class ProjectService {
|
||||
async create(data: CreateProjectInput) {
|
||||
const client = getSupabaseServerClient(); // coupling!
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ BAD: Business logic in the adapter
|
||||
export const createProjectAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
// Business logic directly in the action — not reusable
|
||||
if (data.name.length > 100) throw new Error('Name too long');
|
||||
const { data: result } = await client.from('projects').insert(data);
|
||||
return result;
|
||||
},
|
||||
{ auth: true, schema: CreateProjectSchema },
|
||||
);
|
||||
|
||||
// ❌ BAD: Two interfaces duplicate the same logic
|
||||
// server-actions.ts
|
||||
const result = await client.from('projects').insert(...).select().single();
|
||||
// mcp-tool.ts
|
||||
const result = await client.from('projects').insert(...).select().single();
|
||||
// Should be: both call projectService.create()
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.
|
||||
273
.claude/skills/service-builder/examples.md
Normal file
273
.claude/skills/service-builder/examples.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Service Builder Examples
|
||||
|
||||
## Service with Multiple Dependencies
|
||||
|
||||
When a service needs more than just a database client, inject all dependencies.
|
||||
|
||||
```typescript
|
||||
// _lib/server/invoice.service.ts
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
interface InvoiceServiceDeps {
|
||||
client: SupabaseClient;
|
||||
storage: SupabaseClient['storage'];
|
||||
}
|
||||
|
||||
export function createInvoiceService(deps: InvoiceServiceDeps) {
|
||||
return new InvoiceService(deps);
|
||||
}
|
||||
|
||||
class InvoiceService {
|
||||
constructor(private readonly deps: InvoiceServiceDeps) {}
|
||||
|
||||
async generatePdf(invoiceId: string): Promise<{ url: string }> {
|
||||
const { data: invoice, error } = await this.deps.client
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('id', invoiceId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const pdf = this.buildPdf(invoice);
|
||||
|
||||
const { data: upload, error: uploadError } = await this.deps.storage
|
||||
.from('invoices')
|
||||
.upload(`${invoiceId}.pdf`, pdf);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
return { url: upload.path };
|
||||
}
|
||||
|
||||
private buildPdf(invoice: Record<string, unknown>): Uint8Array {
|
||||
// Pure logic — no I/O
|
||||
// ...
|
||||
return new Uint8Array();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server action adapter:**
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { GenerateInvoiceSchema } from '../schemas/invoice.schema';
|
||||
import { createInvoiceService } from './invoice.service';
|
||||
|
||||
export const generateInvoicePdfAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createInvoiceService({
|
||||
client,
|
||||
storage: client.storage,
|
||||
});
|
||||
|
||||
return service.generatePdf(data.invoiceId);
|
||||
},
|
||||
{ auth: true, schema: GenerateInvoiceSchema },
|
||||
);
|
||||
```
|
||||
|
||||
## Service Composing Other Services
|
||||
|
||||
Services can depend on other services — compose at the adapter level.
|
||||
|
||||
```typescript
|
||||
// _lib/server/onboarding.service.ts
|
||||
import type { ProjectService } from './project.service';
|
||||
import type { NotificationService } from './notification.service';
|
||||
|
||||
interface OnboardingServiceDeps {
|
||||
projects: ProjectService;
|
||||
notifications: NotificationService;
|
||||
}
|
||||
|
||||
export function createOnboardingService(deps: OnboardingServiceDeps) {
|
||||
return new OnboardingService(deps);
|
||||
}
|
||||
|
||||
class OnboardingService {
|
||||
constructor(private readonly deps: OnboardingServiceDeps) {}
|
||||
|
||||
async onboardAccount(params: { accountId: string; accountName: string }) {
|
||||
// Create default project
|
||||
const project = await this.deps.projects.create({
|
||||
name: `${params.accountName}'s First Project`,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
// Send welcome notification
|
||||
await this.deps.notifications.send({
|
||||
accountId: params.accountId,
|
||||
type: 'welcome',
|
||||
data: { projectId: project.id },
|
||||
});
|
||||
|
||||
return { project };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Adapter composes the dependency tree:**
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { createOnboardingService } from './onboarding.service';
|
||||
import { createProjectService } from './project.service';
|
||||
import { createNotificationService } from './notification.service';
|
||||
|
||||
export const onboardAccountAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const service = createOnboardingService({
|
||||
projects: createProjectService(client),
|
||||
notifications: createNotificationService(client),
|
||||
});
|
||||
|
||||
return service.onboardAccount(data);
|
||||
},
|
||||
{ auth: true, schema: OnboardAccountSchema },
|
||||
);
|
||||
```
|
||||
|
||||
## Pure Validation Service (No I/O)
|
||||
|
||||
Some services are entirely pure — they don't even need a database client.
|
||||
|
||||
```typescript
|
||||
// _lib/server/pricing.service.ts
|
||||
|
||||
interface PricingInput {
|
||||
plan: 'starter' | 'pro' | 'enterprise';
|
||||
seats: number;
|
||||
billingPeriod: 'monthly' | 'yearly';
|
||||
}
|
||||
|
||||
interface PricingResult {
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
discount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export function calculatePricing(input: PricingInput): PricingResult {
|
||||
const basePrices = { starter: 900, pro: 2900, enterprise: 9900 };
|
||||
const unitPrice = basePrices[input.plan];
|
||||
const yearlyDiscount = input.billingPeriod === 'yearly' ? 0.2 : 0;
|
||||
const seatDiscount = input.seats >= 10 ? 0.1 : 0;
|
||||
const discount = Math.min(yearlyDiscount + seatDiscount, 0.3);
|
||||
const total = Math.round(unitPrice * input.seats * (1 - discount));
|
||||
|
||||
return { unitPrice, total, discount, currency: 'usd' };
|
||||
}
|
||||
```
|
||||
|
||||
This is the simplest case — a plain function, no class, no dependencies. Trivially testable:
|
||||
|
||||
```typescript
|
||||
import { calculatePricing } from '../pricing.service';
|
||||
|
||||
it('applies yearly discount', () => {
|
||||
const result = calculatePricing({
|
||||
plan: 'pro',
|
||||
seats: 1,
|
||||
billingPeriod: 'yearly',
|
||||
});
|
||||
|
||||
expect(result.discount).toBe(0.2);
|
||||
expect(result.total).toBe(2320); // 2900 * 0.8
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Mock Client
|
||||
|
||||
Full mock pattern for Supabase client:
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* Creates a chainable mock that mimics Supabase's query builder.
|
||||
* Override any method in the chain via the `overrides` param.
|
||||
*/
|
||||
export function createMockSupabaseClient(
|
||||
resolvedValue: { data: unknown; error: unknown } = { data: null, error: null },
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
|
||||
|
||||
// Every method returns `this` (chainable) by default
|
||||
const methods = [
|
||||
'select', 'insert', 'update', 'upsert', 'delete',
|
||||
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in',
|
||||
'like', 'ilike', 'is', 'order', 'limit', 'range',
|
||||
'single', 'maybeSingle',
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
chain[method] = vi.fn().mockReturnThis();
|
||||
}
|
||||
|
||||
// Terminal methods resolve with data
|
||||
chain.single = vi.fn().mockResolvedValue(resolvedValue);
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(resolvedValue);
|
||||
|
||||
// Apply overrides
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
chain[key] = vi.fn().mockImplementation(
|
||||
typeof value === 'function' ? value : () => value,
|
||||
);
|
||||
}
|
||||
|
||||
// Non-terminal chains that don't end with single/maybeSingle
|
||||
// resolve when awaited via .then()
|
||||
const proxyHandler: ProxyHandler<typeof chain> = {
|
||||
get(target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve(resolvedValue);
|
||||
}
|
||||
return target[prop as string] ?? vi.fn().mockReturnValue(target);
|
||||
},
|
||||
};
|
||||
|
||||
const chainProxy = new Proxy(chain, proxyHandler);
|
||||
|
||||
return {
|
||||
from: vi.fn(() => chainProxy),
|
||||
chain,
|
||||
} as unknown as SupabaseClient & { chain: typeof chain };
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
import { createMockSupabaseClient } from '../test-utils';
|
||||
import { createProjectService } from '../project.service';
|
||||
|
||||
it('lists projects for an account', async () => {
|
||||
const projects = [
|
||||
{ id: '1', name: 'Alpha', account_id: 'acc-1' },
|
||||
{ id: '2', name: 'Beta', account_id: 'acc-1' },
|
||||
];
|
||||
|
||||
const client = createMockSupabaseClient({ data: projects, error: null });
|
||||
const service = createProjectService(client);
|
||||
|
||||
const result = await service.list('acc-1');
|
||||
|
||||
expect(result).toEqual(projects);
|
||||
expect(client.from).toHaveBeenCalledWith('projects');
|
||||
expect(client.chain.eq).toHaveBeenCalledWith('account_id', 'acc-1');
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user