356 lines
8.5 KiB
TypeScript
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,
|
|
})
|
|
}
|