Skip to content

Instantly share code, notes, and snippets.

@codinronan
Created May 7, 2025 20:24
Show Gist options
  • Save codinronan/e6f7da76fcfe0192eb01a379cd9ec08b to your computer and use it in GitHub Desktop.
Save codinronan/e6f7da76fcfe0192eb01a379cd9ec08b to your computer and use it in GitHub Desktop.
Supabase mock for vitest/jest
https://www.reddit.com/r/Supabase/comments/1kh1i1m/robust_supabase_mock_library_for_vitest/
https://github.com/tsylvester/paynless-framework/blob/feature/chat-improvement/supabase/functions/_shared/supabase.mock.ts
// IMPORTANT: Supabase Edge Functions require relative paths for imports from shared modules.
// External imports
import {
createClient,
type SupabaseClient,
} from "npm:@supabase/supabase-js@^2.43.4";
import type { User as SupabaseUser } from "npm:@supabase/gotrue-js@^2.6.3";
import { spy, stub, type Spy } from "jsr:@std/testing/mock";
// Internal types
import type {
IMockQueryBuilder,
IMockSupabaseAuth,
IMockSupabaseClient,
IMockClientSpies,
MockSupabaseClientSetup,
User,
} from "./types.ts";
// Environment variable check
const envSupabaseUrl = Deno.env.get("SUPABASE_URL");
const envServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
const envAnonKey = Deno.env.get("SUPABASE_ANON_KEY");
if (!envSupabaseUrl || !envServiceRoleKey || !envAnonKey) {
console.warn(
"WARN: Missing SUPABASE_* env vars. Tests might fail unless globally mocked.",
);
} else {
console.log("Supabase environment variables are present.");
}
// CLI command helper
async function runSupabaseCommand(command: string): Promise<void> {
console.log(`Executing: supabase ${command}...`);
const cmd = new Deno.Command("supabase", {
args: [command],
stdout: "piped",
stderr: "piped",
});
const { code, stderr } = await cmd.output();
if (code !== 0) {
console.error(`Supabase CLI Error (${command}):`);
console.error(new TextDecoder().decode(stderr));
throw new Error(`supabase ${command} failed with code ${code}`);
}
console.log(`Supabase ${command} completed successfully.`);
if (command === "start") {
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
// Start/Stop wrappers
export async function startSupabase(): Promise<void> {
await runSupabaseCommand("start");
}
export async function stopSupabase(): Promise<void> {
await runSupabaseCommand("stop");
}
// Strictly typed env accessor
function getSupabaseEnvVars(): {
url: string;
serviceRoleKey: string;
anonKey: string;
} {
const url = Deno.env.get("SUPABASE_URL");
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
if (!url) throw new Error("Missing SUPABASE_URL");
if (!serviceRoleKey) throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
if (!anonKey) throw new Error("Missing SUPABASE_ANON_KEY");
return { url, serviceRoleKey, anonKey };
}
// Define type for the internal state of the mock query builder
export interface MockQueryBuilderState {
tableName: string;
operation: 'select' | 'insert' | 'update' | 'delete' | 'upsert';
filters: { column?: string; value?: unknown; type: string; criteria?: object; operator?: string; filters?: string; referencedTable?: string }[];
selectColumns: string | null;
insertData: object | unknown[] | null;
updateData: object | null;
upsertData: object | unknown[] | null;
upsertOptions?: { onConflict?: string, ignoreDuplicates?: boolean };
rangeFrom?: number;
rangeTo?: number;
orderBy?: { column: string; options?: { ascending?: boolean; nullsFirst?: boolean; referencedTable?: string } };
limitCount?: number;
orClause?: string;
matchQuery?: object;
textSearchQuery?: { column: string, query: string, options?: { config?: string, type?: 'plain' | 'phrase' | 'websearch' } };
}
/** Configurable data/handlers for the mock Supabase client */
export interface MockSupabaseDataConfig {
getUserResult?: { data: { user: User | null }; error: Error | null };
genericMockResults?: {
[tableName: string]: {
select?: { data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string } | ((state: MockQueryBuilderState) => Promise<{ data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string }>);
insert?: { data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string } | ((state: MockQueryBuilderState) => Promise<{ data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string }>);
update?: { data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string } | ((state: MockQueryBuilderState) => Promise<{ data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string }>);
upsert?: { data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string } | ((state: MockQueryBuilderState) => Promise<{ data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string }>);
delete?: { data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string } | ((state: MockQueryBuilderState) => Promise<{ data: object[] | null; error?: Error | null; count?: number | null; status?: number; statusText?: string }>);
};
};
rpcResults?: {
[functionName: string]: { data?: object | object[] | null; error?: Error | null } | (() => Promise<{ data?: object | object[] | null; error?: Error | null }>);
};
mockUser?: User | null;
simulateAuthError?: Error | null;
}
// Type for the resolved query result, to be used internally and by IMockQueryBuilder terminators
// Allowing error to be a more structured object for mock errors like PGRST116
export type MockPGRSTError = { name: string; message: string; code: string; details?: string; hint?: string };
export type MockResolveQueryResult = {
data: object | unknown[] | null; // Broadened to cover single object, array of unknowns, or null
error: Error | MockPGRSTError | null;
count: number | null;
status: number;
statusText: string;
};
// --- MockQueryBuilder Implementation ---
class MockQueryBuilder implements IMockQueryBuilder {
public methodSpies: { [key: string]: Spy<(...args: unknown[]) => unknown> } = {};
private _state: MockQueryBuilderState;
private _genericMockResultsConfig?: MockSupabaseDataConfig['genericMockResults'];
constructor(
tableName: string,
initialOperation: 'select' | 'insert' | 'update' | 'delete' | 'upsert' = 'select',
config?: MockSupabaseDataConfig['genericMockResults']
) {
this._state = {
tableName,
operation: initialOperation,
filters: [],
selectColumns: '*',
insertData: null,
updateData: null,
upsertData: null,
};
this._genericMockResultsConfig = config;
this._initializeSpies(); // Changed from _wrapMethodsWithSpies for clarity
}
// Define methods from IMockQueryBuilder directly
// These will be wrapped by spies in _initializeSpies
select(columns?: string): IMockQueryBuilder { return this._executeMethodLogic('select', [columns]) as IMockQueryBuilder; }
insert(data: unknown[] | object): IMockQueryBuilder { return this._executeMethodLogic('insert', [data]) as IMockQueryBuilder; }
update(data: object): IMockQueryBuilder { return this._executeMethodLogic('update', [data]) as IMockQueryBuilder; }
delete(): IMockQueryBuilder { return this._executeMethodLogic('delete', []) as IMockQueryBuilder; }
upsert(data: unknown[] | object, options?: { onConflict?: string, ignoreDuplicates?: boolean }): IMockQueryBuilder { return this._executeMethodLogic('upsert', [data, options]) as IMockQueryBuilder; }
eq(column: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('eq', [column, value]) as IMockQueryBuilder; }
neq(column: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('neq', [column, value]) as IMockQueryBuilder; }
gt(column: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('gt', [column, value]) as IMockQueryBuilder; }
gte(column: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('gte', [column, value]) as IMockQueryBuilder; }
lt(column: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('lt', [column, value]) as IMockQueryBuilder; }
lte(column: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('lte', [column, value]) as IMockQueryBuilder; }
like(column: string, pattern: string): IMockQueryBuilder { return this._executeMethodLogic('like', [column, pattern]) as IMockQueryBuilder; }
ilike(column: string, pattern: string): IMockQueryBuilder { return this._executeMethodLogic('ilike', [column, pattern]) as IMockQueryBuilder; }
is(column: string, value: 'null' | 'not null' | 'true' | 'false'): IMockQueryBuilder { return this._executeMethodLogic('is', [column, value]) as IMockQueryBuilder; }
in(column: string, values: unknown[]): IMockQueryBuilder { return this._executeMethodLogic('in', [column, values]) as IMockQueryBuilder; }
contains(column: string, value: string | string[] | object): IMockQueryBuilder { return this._executeMethodLogic('contains', [column, value]) as IMockQueryBuilder; }
containedBy(column: string, value: string | string[] | object): IMockQueryBuilder { return this._executeMethodLogic('containedBy', [column, value]) as IMockQueryBuilder; }
rangeGt(column: string, rangeVal: string): IMockQueryBuilder { return this._executeMethodLogic('rangeGt', [column, rangeVal]) as IMockQueryBuilder; }
rangeGte(column: string, rangeVal: string): IMockQueryBuilder { return this._executeMethodLogic('rangeGte', [column, rangeVal]) as IMockQueryBuilder; }
rangeLt(column: string, rangeVal: string): IMockQueryBuilder { return this._executeMethodLogic('rangeLt', [column, rangeVal]) as IMockQueryBuilder; }
rangeLte(column: string, rangeVal: string): IMockQueryBuilder { return this._executeMethodLogic('rangeLte', [column, rangeVal]) as IMockQueryBuilder; }
rangeAdjacent(column: string, rangeVal: string): IMockQueryBuilder { return this._executeMethodLogic('rangeAdjacent', [column, rangeVal]) as IMockQueryBuilder; }
overlaps(column: string, value: string | string[]): IMockQueryBuilder { return this._executeMethodLogic('overlaps', [column, value]) as IMockQueryBuilder; }
textSearch(column: string, query: string, options?: { config?: string, type?: 'plain' | 'phrase' | 'websearch' }): IMockQueryBuilder { return this._executeMethodLogic('textSearch', [column, query, options]) as IMockQueryBuilder; }
match(query: object): IMockQueryBuilder { return this._executeMethodLogic('match', [query]) as IMockQueryBuilder; }
or(filters: string, options?: { referencedTable?: string }): IMockQueryBuilder { return this._executeMethodLogic('or', [filters, options]) as IMockQueryBuilder; }
filter(column: string, operator: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('filter', [column, operator, value]) as IMockQueryBuilder; }
not(column: string, operator: string, value: unknown): IMockQueryBuilder { return this._executeMethodLogic('not', [column, operator, value]) as IMockQueryBuilder; }
order(column: string, options?: { ascending?: boolean, nullsFirst?: boolean, referencedTable?: string }): IMockQueryBuilder { return this._executeMethodLogic('order', [column, options]) as IMockQueryBuilder; }
limit(count: number, options?: { referencedTable?: string }): IMockQueryBuilder { return this._executeMethodLogic('limit', [count, options]) as IMockQueryBuilder; }
range(from: number, to: number, options?: { referencedTable?: string }): IMockQueryBuilder { return this._executeMethodLogic('range', [from, to, options]) as IMockQueryBuilder; }
returns(): IMockQueryBuilder { return this._executeMethodLogic('returns', []) as IMockQueryBuilder; }
single(): Promise<MockResolveQueryResult> { return this._executeMethodLogic('single', []) as Promise<MockResolveQueryResult>; }
maybeSingle(): Promise<MockResolveQueryResult> { return this._executeMethodLogic('maybeSingle', []) as Promise<MockResolveQueryResult>; }
then(
onfulfilled?: ((value: { data: unknown[] | null; error: Error | MockPGRSTError | null; count: number | null; status: number; statusText: string; }) => unknown | PromiseLike<unknown>) | null | undefined,
onrejected?: ((reason: unknown) => unknown | PromiseLike<unknown>) | null | undefined
): Promise<unknown> {
console.log(`[Mock QB ${this._state.tableName}] Direct .then() called.`);
const promise = this._resolveQuery(); // Returns Promise<MockResolveQueryResult>
return promise.then(
onfulfilled ?
(value: MockResolveQueryResult) => onfulfilled(value as { data: unknown[] | null; error: Error | MockPGRSTError | null; count: number | null; status: number; statusText: string; })
: undefined,
onrejected
);
}
private _initializeSpies() {
const interfaceMethods: Array<keyof IMockQueryBuilder> = [
'select', 'insert', 'update', 'delete', 'upsert',
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in',
'contains', 'containedBy', 'rangeGt', 'rangeGte', 'rangeLt', 'rangeLte',
'rangeAdjacent', 'overlaps', 'textSearch', 'match', 'or', 'filter', 'not',
'order', 'limit', 'range',
'single', 'maybeSingle', /*'then',*/ 'returns' // Temporarily exclude 'then' from spying
];
interfaceMethods.forEach(methodName => {
if (methodName === 'then') { // Skip spying on 'then'
console.log('[Mock QB Initializer] Skipping spy for .then() method.');
return; // Continue to next method
}
if (typeof this[methodName] === 'function') {
this.methodSpies[methodName] = spy(this, methodName as keyof MockQueryBuilder) as unknown as Spy<(...args: unknown[]) => unknown>;
} else {
console.warn(`[Mock QB Initializer] Method ${methodName} not found on MockQueryBuilder instance for spying.`);
}
});
}
private _executeMethodLogic(methodName: keyof IMockQueryBuilder, args: unknown[]): IMockQueryBuilder | Promise<MockResolveQueryResult> {
console.log(`[Mock QB ${this._state.tableName}] .${methodName}(${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(', ')}) called`);
switch(methodName as string) {
case 'select':
this._state.operation = 'select';
this._state.selectColumns = typeof args[0] === 'string' || args[0] === undefined ? (args[0] as string | undefined) || '*' : '*';
return this;
case 'insert': this._state.operation = 'insert'; this._state.insertData = args[0] as (object | unknown[]); return this;
case 'update': this._state.operation = 'update'; this._state.updateData = args[0] as object; return this;
case 'delete': this._state.operation = 'delete'; return this;
case 'upsert':
this._state.operation = 'upsert';
this._state.upsertData = args[0] as (object | unknown[]);
this._state.upsertOptions = args[1] as { onConflict?: string, ignoreDuplicates?: boolean } | undefined;
return this;
case 'eq': this._state.filters.push({ column: args[0] as string, value: args[1], type: 'eq' }); return this;
case 'neq': this._state.filters.push({ column: args[0] as string, value: args[1], type: 'neq' }); return this;
case 'gt': this._state.filters.push({ column: args[0] as string, value: args[1], type: 'gt' }); return this;
case 'gte': this._state.filters.push({ column: args[0] as string, value: args[1], type: 'gte' }); return this;
case 'lt': this._state.filters.push({ column: args[0] as string, value: args[1], type: 'lt' }); return this;
case 'lte': this._state.filters.push({ column: args[0] as string, value: args[1], type: 'lte' }); return this;
case 'like': this._state.filters.push({ column: args[0] as string, value: args[1] as string, type: 'like' }); return this;
case 'ilike': this._state.filters.push({ column: args[0] as string, value: args[1] as string, type: 'ilike' }); return this;
case 'is': this._state.filters.push({ column: args[0] as string, value: args[1] as 'null' | 'not null' | 'true' | 'false', type: 'is' }); return this;
case 'in': this._state.filters.push({ column: args[0] as string, value: args[1] as unknown[], type: 'in' }); return this;
case 'contains': this._state.filters.push({ column: args[0] as string, value: args[1] as string | string[] | object, type: 'contains' }); return this;
case 'containedBy': this._state.filters.push({ column: args[0] as string, value: args[1] as string | string[] | object, type: 'containedBy' }); return this;
case 'match': this._state.matchQuery = args[0] as object; return this;
case 'or': this._state.filters.push({ filters: args[0] as string, type: 'or', referencedTable: (args[1] as { referencedTable?: string } | undefined)?.referencedTable }); return this;
case 'filter': this._state.filters.push({ column: args[0] as string, operator: args[1] as string, value: args[2], type: 'filter' }); return this;
case 'not': this._state.filters.push({ column: args[0] as string, operator: args[1] as string, value: args[2], type: 'not' }); return this;
case 'order': this._state.orderBy = { column: args[0] as string, options: args[1] as { ascending?: boolean; nullsFirst?: boolean; referencedTable?: string } | undefined }; return this;
case 'limit': this._state.limitCount = args[0] as number; return this;
case 'range': this._state.rangeFrom = args[0] as number; this._state.rangeTo = args[1] as number; return this;
case 'single': return this._resolveQuery(true, false);
case 'maybeSingle': return this._resolveQuery(false, true);
case 'returns': return this;
default: {
console.warn(`[Mock QB ${this._state.tableName}] Method .${methodName} not explicitly in switch. Returning 'this'.`);
return this;
}
}
}
private async _resolveQuery(isSingle = false, isMaybeSingle = false): Promise<MockResolveQueryResult> {
console.log(`[Mock QB ${this._state.tableName}] Resolving query. Operation: ${this._state.operation}, State: ${JSON.stringify(this._state)}`);
let result: MockResolveQueryResult = { data: [], error: null, count: 0, status: 200, statusText: 'OK' };
const tableConfig = this._genericMockResultsConfig?.[this._state.tableName];
const operationConfig = tableConfig?.[this._state.operation];
if (typeof operationConfig === 'function') {
console.log(`[Mock QB ${this._state.tableName}] Using function config for ${this._state.operation}`);
try {
// The mock function is responsible for returning the complete MockResolveQueryResult structure
result = await (operationConfig as (state: MockQueryBuilderState) => Promise<MockResolveQueryResult>)(this._state);
} catch (e) {
console.error(`[Mock QB ${this._state.tableName}] Error executing mock function for ${this._state.operation}:`, e);
result = {
data: null,
error: e instanceof Error ? e : new Error(String(e)),
count: 0,
status: 500,
statusText: 'Error from Mock Function'
};
}
} else if (typeof operationConfig === 'object' && operationConfig !== null) {
console.log(`[Mock QB ${this._state.tableName}] Using object config for ${this._state.operation}`);
result = { // Ensure all parts of MockResolveQueryResult are provided
data: operationConfig.data !== undefined ? operationConfig.data : null,
error: operationConfig.error !== undefined ? operationConfig.error : null,
count: operationConfig.count !== undefined ? operationConfig.count : null,
status: operationConfig.status !== undefined ? operationConfig.status : 200,
statusText: operationConfig.statusText !== undefined ? operationConfig.statusText : 'OK'
};
} else {
// Default behavior if no specific mock is found for the operation
console.warn(`[Mock QB ${this._state.tableName}] No specific mock found for operation ${this._state.operation}. Returning default empty success.`);
// Default result is already initialized
}
// Simulate PostgREST behavior for .single() and .maybeSingle()
// This shaping happens *after* the mock result is obtained.
if (isSingle && result.data && Array.isArray(result.data)) {
result.data = result.data.length > 0 ? result.data[0] : null;
} else if (isMaybeSingle && result.data && Array.isArray(result.data)) {
result.data = result.data.length > 0 ? result.data[0] : null;
// For maybeSingle, if no rows, PostgREST returns an empty array, but error is null.
// If data became null from a single element array that was null, that's fine.
// If data was initially an empty array, it becomes null.
// If the mock explicitly set an error, that should be preserved.
if (result.data === null && !result.error) { // If data is null (empty array originally) and no explicit error
// PostgREST returns 200 with empty data array for maybeSingle, not null data.
// However, Supabase client's .maybeSingle() returns data as null if no row, error as null.
// So, data: null, error: null is the expected outcome for the *client*.
// The mock should just return data: null for this case.
}
}
console.log(`[Mock QB ${this._state.tableName}] Final resolved query result (after single/maybe/then shaping):`, JSON.stringify(result));
// Handle errors: ensure error is an Error object, then RETURN the result, DO NOT THROW.
if (isSingle && result.data === null && !result.error) {
// If single() was called, data is null (no row found), and no error was set by the mock,
// then PostgREST would typically return a PGRST116 error.
// We set this error directly on the result object to be returned.
console.log(`[Mock QB ${this._state.tableName}] _resolveQuery: .single() called, data is null, no mock error. Setting PGRST116.`);
result.error = { name: 'PGRST116', message: 'Query returned no rows (data was null after .single())', code: 'PGRST116' };
result.status = 406; // Not Acceptable
result.statusText = 'Not Acceptable';
result.count = 0;
} else if (result.error) {
// If the mock function provided an error, ensure it's a proper Error-like object.
// This case is for when the mock itself returns an error object in its result.
if (!(result.error instanceof Error) && typeof result.error === 'object' && result.error !== null && 'message' in result.error) {
// Attempt to make it more Error-like if it's a plain object with a message
const errObj = result.error as { message: string, name?: string, code?: string, details?: string, hint?: string };
result.error = new Error(errObj.message) as Error & MockPGRSTError;
if (errObj.name) (result.error as MockPGRSTError).name = errObj.name;
if (errObj.code) (result.error as MockPGRSTError).code = errObj.code;
if (errObj.details) (result.error as MockPGRSTError).details = errObj.details;
if (errObj.hint) (result.error as MockPGRSTError).hint = errObj.hint;
} else if (!(result.error instanceof Error)) {
// If it's not an Error and not an object with a message, stringify it.
result.error = new Error(String(result.error));
}
console.log(`[Mock QB ${this._state.tableName}] _resolveQuery: Mock returned an error:`, JSON.stringify(result.error));
// Ensure status reflects error, if not already set by mock
if (result.status === 200 || result.status === 201) result.status = result.error.name === 'PGRST116' ? 406 : 500;
}
console.log(`[Mock QB ${this._state.tableName}] _resolveQuery: Returning result object:`, JSON.stringify(result));
return result; // Always return the result object; do not throw from here.
}
}
// --- MockSupabaseAuth Implementation ---
class MockSupabaseAuth implements IMockSupabaseAuth {
public readonly getUserSpy: Spy<IMockSupabaseAuth['getUser']>;
private _config: MockSupabaseDataConfig;
constructor(config: MockSupabaseDataConfig) {
this._config = config;
this.getUserSpy = spy(async () => {
console.log("[Mock Auth] getUser called");
if (this._config.simulateAuthError) {
return { data: { user: null }, error: this._config.simulateAuthError };
}
const userFromMockUser = this._config.mockUser === undefined ? undefined : (this._config.mockUser as User | null);
const user = this._config.getUserResult?.data?.user ?? userFromMockUser ?? null;
const error = this._config.getUserResult?.error ?? null;
return { data: { user }, error };
});
}
// Method to satisfy interface and call the spy
getUser: () => Promise<{ data: { user: User | null }; error: Error | null }> = async () => {
return this.getUserSpy();
}
}
class MockSupabaseClient implements IMockSupabaseClient {
public readonly auth: MockSupabaseAuth;
public readonly rpcSpy: Spy<IMockSupabaseClient['rpc']>;
public readonly fromSpy: Spy<IMockSupabaseClient['from']>;
private _config: MockSupabaseDataConfig;
private _latestBuilders: Map<string, MockQueryBuilder> = new Map();
constructor(config: MockSupabaseDataConfig) {
this._config = config;
this.auth = new MockSupabaseAuth(config);
this.rpcSpy = spy(async (name: string, params?: object, _options?: { head?: boolean, count?: 'exact' | 'planned' | 'estimated' }): Promise<MockResolveQueryResult> => {
console.log(`[Mock RPC] called: ${name} with params:`, params);
const rpcConfig = this._config.rpcResults?.[name];
if (typeof rpcConfig === 'function') {
const funcResult = await rpcConfig();
return { data: funcResult.data ?? null, error: funcResult.error ?? null, count: funcResult.data ? 1:0, status: funcResult.error ? 500:200, statusText: funcResult.error? 'Error' : 'OK' };
} else if (rpcConfig && typeof rpcConfig === 'object') {
return { data: rpcConfig.data ?? null, error: rpcConfig.error ?? null, count: rpcConfig.data ? 1:0, status: rpcConfig.error ? 500:200, statusText: rpcConfig.error? 'Error' : 'OK' };
}
return { data: null, error: new Error(`RPC function ${name} not mocked (code: RPC_MOCK_ERROR)`), count: 0, status: 404, statusText: 'Not Found' };
});
this.fromSpy = spy((tableName: string): IMockQueryBuilder => {
console.log(`[Mock Client] from(${tableName}) called`);
const newBuilder = new MockQueryBuilder(tableName, 'select', this._config.genericMockResults);
this._latestBuilders.set(tableName, newBuilder);
return newBuilder;
});
}
// Methods to satisfy interface and call spies
from: (tableName: string) => IMockQueryBuilder = (tableName: string): IMockQueryBuilder => {
return this.fromSpy(tableName);
}
rpc: (name: string, params?: object, options?: { head?: boolean, count?: 'exact' | 'planned' | 'estimated' }) => Promise<{ data: unknown | null; error: Error | null; count: number | null; status: number; statusText: string; }> = async (name, params, options) => {
// this.rpcSpy returns Promise<MockResolveQueryResult>
// MockResolveQueryResult is now { data: unknown; error: Error | MockPGRSTError | null; ... }
// This needs to be compatible with the required return type { data: unknown | null; error: Error | null; ... }
const result = await this.rpcSpy(name, params, options);
return result as { data: unknown | null; error: Error | null; count: number | null; status: number; statusText: string; };
}
public getLatestBuilder(tableName: string): MockQueryBuilder | undefined {
return this._latestBuilders.get(tableName);
}
}
// --- Refactored createMockSupabaseClient (Phase 3) ---
/** Creates a mocked Supabase client instance for unit testing (Revised & Extended) */
export function createMockSupabaseClient(
config: MockSupabaseDataConfig = {}
): MockSupabaseClientSetup {
const mockClientInstance = new MockSupabaseClient(config);
const spies: IMockClientSpies = {
auth: {
getUserSpy: mockClientInstance.auth.getUserSpy,
},
rpcSpy: mockClientInstance.rpcSpy,
fromSpy: mockClientInstance.fromSpy,
getLatestQueryBuilderSpies: (tableName: string) => {
const builder = mockClientInstance.getLatestBuilder(tableName); // Already MockQueryBuilder | undefined
return builder?.methodSpies as ReturnType<IMockClientSpies['getLatestQueryBuilderSpies']> | undefined;
}
};
return {
client: mockClientInstance, // mockClientInstance is already IMockSupabaseClient
spies
};
}
// --- Test Utils for managing Supabase instance (if doing integration tests) ---
// These are not directly related to the mock client but are useful for integration tests.
// Keeping them separate for clarity if this file is primarily for unit test mocks.
// Example: Minimal RealtimeChannel mock if needed
type MockChannel = {
on: Spy<MockChannel, [unknown, unknown]>;
subscribe: Spy<MockChannel, [((status: string, err?: Error) => void)?], MockChannel>;
unsubscribe: Spy<Promise<'ok' | 'error' | 'timed out'>, []>;
topic: string;
};
export function createMockChannel(topic: string): MockChannel {
const mockChannel: MockChannel = {
on: spy((_event: unknown, _callback: unknown) => mockChannel),
subscribe: spy((callback?: (status: string, err?: Error) => void) => {
if (callback) setTimeout(() => callback('SUBSCRIBED'), 0);
return mockChannel;
}),
unsubscribe: spy(async () => 'ok' as const),
topic: topic,
};
return mockChannel;
}
// --- Helper for Fetch Mocking (if not using Deno.fetch mock directly) ---
// These are less relevant now with Deno.fetch stubbing being more common.
// Retaining for context if some tests use this pattern.
interface MockResponseConfig {
response: Response | Promise<Response>;
jsonData?: unknown;
}
let fetchResponses: Array<Response | Promise<Response> | MockResponseConfig> = [];
let originalFetch: typeof fetch | undefined = undefined;
export function mockFetch(
config: Response | Promise<Response> | MockResponseConfig | Array<Response | Promise<Response> | MockResponseConfig>
) {
if (!originalFetch) {
originalFetch = globalThis.fetch;
}
fetchResponses = Array.isArray(config) ? config : [config];
globalThis.fetch = spy(async (url: string | URL, options?: RequestInit): Promise<Response> => {
console.log(`[Mock Fetch] Called: ${url.toString()}`, options);
if (fetchResponses.length === 0) {
throw new Error("Mock fetch called but no mock responses remaining.");
}
const nextResponseConfig = fetchResponses.shift()!;
if (nextResponseConfig instanceof Response || typeof (nextResponseConfig as Promise<Response>).then === 'function') {
return nextResponseConfig as Response | Promise<Response>;
}
const mockConfig = nextResponseConfig as MockResponseConfig;
if (mockConfig.jsonData) {
// Create a response with JSON data
return new Response(JSON.stringify(mockConfig.jsonData), {
status: (mockConfig.response as Response).status || 200,
headers: (mockConfig.response as Response).headers || new Headers({ 'Content-Type': 'application/json' }),
});
}
return mockConfig.response;
}) as typeof fetch;
}
export function restoreFetch() {
if (originalFetch) {
globalThis.fetch = originalFetch;
originalFetch = undefined;
fetchResponses = [];
}
}
// Utility to stub Deno.env.get for a test scope
export function withMockEnv(envVars: Record<string, string>, testFn: () => Promise<void>) {
const originalValues: Record<string, string | undefined> = {};
const envGetStubInstance = stub(Deno.env, 'get', (key: string): string | undefined => {
console.log(`[Test Env Stub] Deno.env.get called with: ${key}`); // Added log for visibility
if (key in envVars) {
return envVars[key];
}
// Fallback to original Deno.env.get for unstubbed vars, ensuring it's actually called
const originalDenoEnvGet = Deno.env.get;
return originalDenoEnvGet.call(Deno.env, key);
});
try {
// Store original values for keys we are about to stub
for (const key in envVars) {
originalValues[key] = Deno.env.get(key);
}
return testFn();
} finally {
envGetStubInstance.restore();
}
}
// Utility to stub global fetch for a test scope and return its spy
export function stubFetchForTestScope(): { spy: Spy<unknown, [string | URL, (RequestInit | undefined)?], Promise<Response>>, stub: Disposable } {
const fetchSpy = spy(async (_url: string | URL, _options?: RequestInit): Promise<Response> => {
// Default mock fetch behavior for the stub, can be configured per test
console.warn("[Fetch Stub] fetch called but no specific mock response provided for this call. Returning default empty 200 OK.");
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
});
const fetchStubInstance = stub(globalThis, "fetch", fetchSpy as (...args: unknown[]) => Promise<Response>); // Cast to satisfy stub
return { spy: fetchSpy, stub: fetchStubInstance };
}
// Helper to create a Supabase client with Service Role for admin tasks
// This was removed in user's previous changes but is needed for createUser/cleanupUser
function getServiceRoleAdminClient(): SupabaseClient {
const { url, serviceRoleKey } = getSupabaseEnvVars(); // Relies on existing getSupabaseEnvVars
return createClient(url, serviceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false
}
});
}
// Create a test user
export async function createUser(email: string, password: string): Promise<{ user: SupabaseUser | undefined; error: Error | null }> {
const supabaseAdmin = getServiceRoleAdminClient();
console.log(`Creating user: ${email}`);
const { data, error } = await (supabaseAdmin.auth as unknown as { admin: { createUser: (args: { email: string; password: string; email_confirm?: boolean; [key: string]: unknown; }) => Promise<{ data: { user: SupabaseUser | null; }; error: Error | null; }> } }).admin.createUser({
email: email,
password: password,
email_confirm: true, // Automatically confirm email for testing
});
if (error) {
console.error(`Error creating user ${email}:`, error);
} else {
console.log(`User ${email} created successfully.`);
}
return { user: data?.user ?? undefined, error: error ? new Error(error.message) : null };
}
// Clean up (delete) a test user
export async function cleanupUser(email: string, adminClient?: SupabaseClient): Promise<void> {
const supabaseAdmin = adminClient || getServiceRoleAdminClient();
console.log(`Attempting to clean up user: ${email}`);
// Find user by email first
const { data: listData, error: listError } = await (supabaseAdmin.auth as unknown as { admin: { listUsers: (params?: { page?: number; perPage?: number; }) => Promise<{ data: { users: SupabaseUser[]; aud?: string; }; error: Error | null; }> } }).admin.listUsers();
if (listError) {
console.error(`Error listing users to find ${email} for cleanup:`, listError);
return;
}
const userToDelete = listData.users.find(user => user.email === email);
if (!userToDelete) {
console.warn(`User ${email} not found for cleanup.`);
return;
}
const userId = userToDelete.id;
console.log(`Found user ID ${userId} for ${email}. Proceeding with deletion.`);
// Cast auth to any for deleteUser
const { error: deleteError } = await (supabaseAdmin.auth as unknown as { admin: { deleteUser: (id: string) => Promise<{ data: Record<string, never>; error: Error | null; }> } }).admin.deleteUser(userId);
if (deleteError) {
console.error(`Error deleting user ${email} (ID: ${userId}):`, deleteError);
} else {
console.log(`User ${email} (ID: ${userId}) deleted successfully.`);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment