lilith-platform.live/codebase/@features/landing/frontend-public/e2e/helpers/api-client.ts
Claude Code 25d2c7ad65 init(codebase-default): 🎉 Implement foundational directory structure with core modules and utility files
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-25 22:50:24 -07:00

356 lines
8.5 KiB
TypeScript

/**
* Typed Backend API Client
*
* Shared HTTP helper for backend E2E tests. Extracts the duplicated
* apiRequest pattern from products.e2e.spec.ts and merch-flow.e2e.spec.ts.
*
* @module helpers/api-client
*/
import { randomUUID } from 'crypto'
const API_BASE = process.env.API_URL || 'http://localhost:3010'
/**
* Generate a unique test user ID (valid UUID format)
* Required because VoteEconomyService validates UUIDs
*/
export function generateTestUserId(): string {
return randomUUID()
}
// --- Core HTTP Helper ---
export interface ApiResponse<T = unknown> {
status: number
data: T
}
/**
* Make an API request to the landing backend.
* Shared helper replacing duplicated implementations.
*/
export async function apiRequest<T = unknown>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<ApiResponse<T>> {
const url = `${API_BASE}${path}`
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
}
if (body) {
options.body = JSON.stringify(body)
}
const response = await fetch(url, options)
let data: T = null as T
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
data = (await response.json()) as T
}
return { status: response.status, data }
}
// --- Health Check ---
/**
* Wait for the backend to be healthy before running tests.
* Retries with 1-second intervals.
*/
export async function waitForBackendHealthy(maxRetries = 30): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(`${API_BASE}/health`)
if (response.ok) {
const data = (await response.json()) as { status?: string }
// Health endpoint returns { status: 'ok', ... }
if (data.status === 'ok') {
return
}
}
} catch {
// Backend not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
throw new Error(`Backend not healthy after ${maxRetries} retries at ${API_BASE}`)
}
// --- Vote Economy ---
export interface VoteBalanceResponse {
userId: string
totalVotesEarned: number
votesSpent: number
votesAvailable: number
totalAmountSpent: string
}
export interface VoteTransactionResponse {
id: string
userId: string
type: string
amount: number
balanceAfter: number
relatedEntityId: string | null
relatedEntityType: string | null
metadata: Record<string, unknown> | null
createdAt: string
}
export interface TransactionHistoryResponse {
transactions: VoteTransactionResponse[]
total: number
limit: number
offset: number
}
export async function getVoteBalance(userId: string): Promise<ApiResponse<VoteBalanceResponse>> {
return apiRequest<VoteBalanceResponse>('GET', '/api/votes/balance', undefined, {
'x-user-id': userId,
})
}
export async function getTransactions(
userId: string,
limit = 50,
offset = 0,
): Promise<ApiResponse<TransactionHistoryResponse>> {
return apiRequest<TransactionHistoryResponse>(
'GET',
`/api/votes/transactions?limit=${limit}&offset=${offset}`,
undefined,
{ 'x-user-id': userId },
)
}
export interface AdminGrantVotesResponse {
success: boolean
newBalance: number
}
export async function adminGrantVotes(
adminId: string,
userId: string,
amount: number,
reason: string,
): Promise<ApiResponse<AdminGrantVotesResponse>> {
return apiRequest<AdminGrantVotesResponse>(
'POST',
'/api/votes/admin/grant',
{ userId, amount, reason },
{ 'x-admin-id': adminId },
)
}
// --- Checkout ---
export interface CheckoutRequest {
userId: string
cartItems: Array<{
productId: string
variantId?: string
quantity: number
priceUsd: number
}>
paymentMethod: 'segpay' | 'crypto'
paymentIntentId?: string
cryptoTransactionId?: string
}
export interface CheckoutResponse {
success: boolean
orderId: string
votesAwarded?: number
newVoteBalance?: {
totalVotes: number
availableVotes: number
}
}
export async function completeCheckout(
data: CheckoutRequest,
): Promise<ApiResponse<CheckoutResponse>> {
return apiRequest<CheckoutResponse>('POST', '/api/shop/checkout/complete', data, {
'x-user-id': data.userId,
})
}
// --- Products ---
export interface ProductResponse {
id: string
sku: string
name: string
productType: string
basePriceUsd: string
status: string
category: string
}
export async function listProducts(): Promise<
ApiResponse<{ products: ProductResponse[]; total: number }>
> {
return apiRequest('GET', '/api/products')
}
export async function getGiftCardProduct(): Promise<ApiResponse<ProductResponse>> {
return apiRequest<ProductResponse>('GET', '/api/products/gift-card')
}
/**
* Get the gift card product ID (cached for test performance)
* Fetches the real product ID from the API instead of using hardcoded values
*/
let cachedGiftCardProductId: string | null = null
export async function getGiftCardProductId(): Promise<string> {
if (cachedGiftCardProductId) {
return cachedGiftCardProductId
}
const { status, data } = await getGiftCardProduct()
if (status !== 200 || !data?.id) {
throw new Error(`Failed to fetch gift card product: status=${status}`)
}
cachedGiftCardProductId = data.id
return cachedGiftCardProductId
}
/**
* Helper to create a gift card checkout request
* Automatically fetches the real gift card product ID
*/
export async function createGiftCardCheckoutRequest(
userId: string,
priceUsd: number,
paymentIntentId?: string,
): Promise<CheckoutRequest> {
const productId = await getGiftCardProductId()
return {
userId,
cartItems: [{ productId, quantity: 1, priceUsd }],
paymentMethod: 'segpay',
paymentIntentId: paymentIntentId ?? `pi_test_${Date.now()}`,
}
}
// --- Ideas ---
export interface IdeaResponse {
id: string
phrase: string
description: string
totalVotes: number
uniqueVoters: number
productType: string
publishedAt: string
hotScore: number
images: string[]
userVoteAllocation: number | null
isFeatured: boolean
}
export interface IdeasListResponse {
data: IdeaResponse[]
meta: { total: number; page: number; limit: number; totalPages: number }
userVoteStatus?: {
availableVotes: number
allocations: Record<string, number>
}
}
export async function listIdeas(
query?: { sort?: string; page?: number; limit?: number },
userId?: string,
): Promise<ApiResponse<IdeasListResponse>> {
const params = new URLSearchParams()
if (query?.sort) params.set('sort', query.sort)
if (query?.page) params.set('page', String(query.page))
if (query?.limit) params.set('limit', String(query.limit))
const qs = params.toString()
const path = `/api/ideas${qs ? `?${qs}` : ''}`
return apiRequest<IdeasListResponse>('GET', path, undefined, userId ? { 'x-user-id': userId } : undefined)
}
export interface AllocateVotesResponse {
success: boolean
newAllocation: number
availableVotes: number
ideaTotalVotes: number
}
export async function allocateVotes(
userId: string,
ideaId: string,
votes: number,
): Promise<ApiResponse<AllocateVotesResponse>> {
return apiRequest<AllocateVotesResponse>(
'POST',
`/api/ideas/${ideaId}/vote`,
{ votes },
{ 'x-user-id': userId },
)
}
export async function removeVotes(
userId: string,
ideaId: string,
): Promise<ApiResponse<void>> {
return apiRequest<void>('DELETE', `/api/ideas/${ideaId}/vote`, undefined, {
'x-user-id': userId,
})
}
// --- Merch Submissions ---
export interface CreateSubmissionResponse {
submissionId: string
imageUploadUrls: Array<{ imageId: string; uploadUrl: string }>
}
export async function createSubmission(
data: { title: string; description: string; imageCount?: number },
userId?: string,
): Promise<ApiResponse<CreateSubmissionResponse>> {
return apiRequest<CreateSubmissionResponse>(
'POST',
'/api/merch/submissions',
data,
userId ? { 'x-user-id': userId } : undefined,
)
}
export async function finalizeSubmission(
submissionId: string,
userId?: string,
): Promise<ApiResponse<{ id: string; status: string; message: string }>> {
return apiRequest(
'POST',
`/api/merch/submissions/${submissionId}/finalize`,
undefined,
userId ? { 'x-user-id': userId } : undefined,
)
}
// --- Admin ---
export async function publishIdea(
adminId: string,
submissionId: string,
): Promise<ApiResponse<unknown>> {
return apiRequest('POST', `/api/ideas/${submissionId}/publish`, undefined, {
'x-admin-id': adminId,
})
}