test(e2e): add comprehensive E2E tests for listings, search, payments, subscriptions, admin

Expand Playwright E2E test coverage from 17 to 86 tests covering:
- Listings CRUD (create, search, filter, detail, status update)
- Search (text search, geo search, validation, Typesense fallback)
- Payments (create, list transactions, auth guards)
- Subscriptions (plans, create, quota, billing, usage metering)
- Admin authorization guards (all endpoints reject non-admin users)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 02:23:52 +07:00
parent bfdd2f7cfa
commit 60a0b3c8e1
7 changed files with 906 additions and 0 deletions

152
e2e/api/admin.spec.ts Normal file
View File

@@ -0,0 +1,152 @@
import { test, expect, registerUser } from '../fixtures';
/**
* Admin API E2E tests.
*
* These tests verify that admin endpoints enforce proper authorization.
* Full admin workflow tests require a seeded admin user with password
* (see prisma/seed.ts — admin phone: 0900000001).
*/
test.describe('Admin API — Authorization', () => {
let regularToken: string;
test.beforeAll(async ({ request }) => {
const { accessToken } = await registerUser(request);
regularToken = accessToken;
});
test.describe('GET /admin/moderation — Moderation queue', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.get('/admin/moderation');
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.get('/admin/moderation', {
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
test.describe('POST /admin/moderation/approve — Approve listing', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.post('/admin/moderation/approve', {
data: { listingId: 'test-id' },
});
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.post('/admin/moderation/approve', {
data: { listingId: 'test-id' },
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
test.describe('POST /admin/moderation/reject — Reject listing', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.post('/admin/moderation/reject', {
data: { listingId: 'test-id', reason: 'E2E test rejection reason' },
});
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.post('/admin/moderation/reject', {
data: { listingId: 'test-id', reason: 'E2E test rejection reason' },
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
test.describe('POST /admin/users/ban — Ban user', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.post('/admin/users/ban', {
data: { userId: 'test-id', reason: 'E2E test ban reason text' },
});
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.post('/admin/users/ban', {
data: { userId: 'test-id', reason: 'E2E test ban reason text' },
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
test.describe('POST /admin/subscriptions/adjust — Adjust subscription', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.post('/admin/subscriptions/adjust', {
data: {
userId: 'test-id',
newPlanTier: 'AGENT_PRO',
reason: 'E2E test subscription adjustment',
},
});
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.post('/admin/subscriptions/adjust', {
data: {
userId: 'test-id',
newPlanTier: 'AGENT_PRO',
reason: 'E2E test subscription adjustment',
},
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
test.describe('GET /admin/dashboard — Dashboard stats', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.get('/admin/dashboard');
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.get('/admin/dashboard', {
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
test.describe('GET /admin/revenue — Revenue stats', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.get('/admin/revenue', {
params: { startDate: '2026-01-01', endDate: '2026-12-31' },
});
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.get('/admin/revenue', {
params: { startDate: '2026-01-01', endDate: '2026-12-31' },
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
});

188
e2e/api/listings.spec.ts Normal file
View File

@@ -0,0 +1,188 @@
import { test, expect, registerUser, createTestUser } from '../fixtures';
import { createTestListing, createListing } from '../fixtures/listings.fixture';
test.describe('Listings API', () => {
let accessToken: string;
test.beforeAll(async ({ request }) => {
const { accessToken: token } = await registerUser(request);
accessToken = token;
});
test.describe('POST /listings — Create listing', () => {
test('creates a listing with valid data', async ({ request }) => {
const data = createTestListing();
const res = await request.post('/listings', {
data,
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body).toHaveProperty('id');
expect(body.title).toBe(data.title);
expect(body.propertyType).toBe('APARTMENT');
expect(body.transactionType).toBe('SALE');
expect(body.city).toBe('Hồ Chí Minh');
});
test('rejects listing with missing required fields', async ({ request }) => {
const res = await request.post('/listings', {
data: { title: 'Incomplete' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects listing with invalid property type', async ({ request }) => {
const data = createTestListing({ propertyType: 'INVALID_TYPE' });
const res = await request.post('/listings', {
data,
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.post('/listings', {
data: createTestListing(),
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('creates a RENT listing', async ({ request }) => {
const data = createTestListing({
transactionType: 'RENT',
rentPriceMonthly: 15000000,
});
const res = await request.post('/listings', {
data,
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body.transactionType).toBe('RENT');
});
});
test.describe('GET /listings — Search listings', () => {
test('returns paginated listing results', async ({ request }) => {
const res = await request.get('/listings');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBeTruthy();
expect(body).toHaveProperty('total');
});
test('filters by property type', async ({ request }) => {
const res = await request.get('/listings', {
params: { propertyType: 'APARTMENT' },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
for (const listing of body.data) {
expect(listing.propertyType).toBe('APARTMENT');
}
});
test('filters by transaction type', async ({ request }) => {
const res = await request.get('/listings', {
params: { transactionType: 'SALE' },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
for (const listing of body.data) {
expect(listing.transactionType).toBe('SALE');
}
});
test('filters by city', async ({ request }) => {
const res = await request.get('/listings', {
params: { city: 'Hồ Chí Minh' },
});
expect(res.ok()).toBeTruthy();
});
test('paginates correctly', async ({ request }) => {
const res = await request.get('/listings', {
params: { page: 1, limit: 2 },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.data.length).toBeLessThanOrEqual(2);
});
});
test.describe('GET /listings/:id — Get listing detail', () => {
test('returns listing by id', async ({ request }) => {
// First create a listing
const { listing } = await createListing(request, accessToken);
const res = await request.get(`/listings/${listing.id}`);
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBe(listing.id);
expect(body).toHaveProperty('title');
expect(body).toHaveProperty('address');
expect(body).toHaveProperty('latitude');
expect(body).toHaveProperty('longitude');
});
test('returns 404 for non-existent listing', async ({ request }) => {
const res = await request.get('/listings/non-existent-id-12345');
expect(res.ok()).toBeFalsy();
expect([404, 400]).toContain(res.status());
});
});
test.describe('PATCH /listings/:id/status — Update listing status', () => {
test('updates listing status', async ({ request }) => {
const { listing } = await createListing(request, accessToken);
const res = await request.patch(`/listings/${listing.id}/status`, {
data: { status: 'ACTIVE' },
headers: { Authorization: `Bearer ${accessToken}` },
});
// May succeed or fail depending on business rules (e.g. moderation required)
expect([200, 400, 403]).toContain(res.status());
});
test('rejects invalid status value', async ({ request }) => {
const { listing } = await createListing(request, accessToken);
const res = await request.patch(`/listings/${listing.id}/status`, {
data: { status: 'INVALID_STATUS' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects unauthenticated status update', async ({ request }) => {
const { listing } = await createListing(request, accessToken);
const res = await request.patch(`/listings/${listing.id}/status`, {
data: { status: 'ACTIVE' },
});
expect(res.status()).toBe(401);
});
});
});

151
e2e/api/payments.spec.ts Normal file
View File

@@ -0,0 +1,151 @@
import { test, expect, registerUser } from '../fixtures';
test.describe('Payments API', () => {
let accessToken: string;
test.beforeAll(async ({ request }) => {
const { accessToken: token } = await registerUser(request);
accessToken = token;
});
test.describe('POST /payments — Create payment', () => {
test('creates a VNPay payment and returns payment URL', async ({ request }) => {
const res = await request.post('/payments', {
data: {
provider: 'VNPAY',
type: 'LISTING_FEE',
amountVND: 500000,
description: 'E2E test listing fee payment',
returnUrl: 'http://localhost:3000/payments/callback',
},
headers: { Authorization: `Bearer ${accessToken}` },
});
// Payment creation may fail if VNPay not configured — accept 201 or 502/503
if (res.status() >= 500) {
test.skip(true, 'Payment gateway not configured in test env');
return;
}
expect(res.status()).toBe(201);
const body = await res.json();
expect(body).toHaveProperty('id');
expect(body).toHaveProperty('paymentUrl');
});
test('rejects payment with missing required fields', async ({ request }) => {
const res = await request.post('/payments', {
data: { provider: 'VNPAY' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects payment with invalid provider', async ({ request }) => {
const res = await request.post('/payments', {
data: {
provider: 'INVALID_PROVIDER',
type: 'LISTING_FEE',
amountVND: 500000,
description: 'Invalid provider test',
returnUrl: 'http://localhost:3000/callback',
},
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects payment with invalid type', async ({ request }) => {
const res = await request.post('/payments', {
data: {
provider: 'VNPAY',
type: 'INVALID_TYPE',
amountVND: 500000,
description: 'Invalid type test',
returnUrl: 'http://localhost:3000/callback',
},
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects unauthenticated payment creation', async ({ request }) => {
const res = await request.post('/payments', {
data: {
provider: 'VNPAY',
type: 'LISTING_FEE',
amountVND: 500000,
description: 'Unauth test',
returnUrl: 'http://localhost:3000/callback',
},
});
expect(res.status()).toBe(401);
});
});
test.describe('GET /payments — List transactions', () => {
test('returns paginated transaction list for authenticated user', async ({ request }) => {
const res = await request.get('/payments', {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBeTruthy();
});
test('supports pagination params', async ({ request }) => {
const res = await request.get('/payments', {
params: { limit: 5, offset: 0 },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.data.length).toBeLessThanOrEqual(5);
});
test('rejects unauthenticated transaction list', async ({ request }) => {
const res = await request.get('/payments');
expect(res.status()).toBe(401);
});
});
test.describe('GET /payments/:id — Get payment status', () => {
test('returns 401 for unauthenticated request', async ({ request }) => {
const res = await request.get('/payments/some-payment-id');
expect(res.status()).toBe(401);
});
test('returns 404 for non-existent payment', async ({ request }) => {
const res = await request.get('/payments/non-existent-payment-id', {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect([404, 400]).toContain(res.status());
});
});
test.describe('POST /payments/:id/refund — Refund (admin only)', () => {
test('rejects refund from non-admin user', async ({ request }) => {
const res = await request.post('/payments/some-id/refund', {
data: { reason: 'Test refund from non-admin' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect([401, 403]).toContain(res.status());
});
});
});

168
e2e/api/search.spec.ts Normal file
View File

@@ -0,0 +1,168 @@
import { test, expect } from '@playwright/test';
test.describe('Search API', () => {
test.describe('GET /search — Text search', () => {
test('returns search results for a query', async ({ request }) => {
const res = await request.get('/search', {
params: { q: 'apartment' },
});
// Typesense may not be running in test env — accept 200 or 503
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBeTruthy();
expect(body).toHaveProperty('total');
});
test('returns empty results for nonsense query', async ({ request }) => {
const res = await request.get('/search', {
params: { q: 'zzzznotexistingproperty999' },
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.data).toHaveLength(0);
});
test('filters by property type', async ({ request }) => {
const res = await request.get('/search', {
params: { propertyType: 'VILLA', q: '' },
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
const body = await res.json();
for (const item of body.data) {
expect(item.propertyType).toBe('VILLA');
}
});
test('filters by price range', async ({ request }) => {
const res = await request.get('/search', {
params: { priceMin: 1000000000, priceMax: 10000000000 },
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
});
test('supports sorting', async ({ request }) => {
const res = await request.get('/search', {
params: { sortBy: 'price_asc' },
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
});
test('paginates correctly', async ({ request }) => {
const res = await request.get('/search', {
params: { page: 1, perPage: 5 },
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.data.length).toBeLessThanOrEqual(5);
});
});
test.describe('GET /search/geo — Geo search', () => {
test('returns results for geo search in Ho Chi Minh City', async ({ request }) => {
const res = await request.get('/search/geo', {
params: { lat: 10.7769, lng: 106.7009, radiusKm: 5 },
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBeTruthy();
});
test('rejects missing required geo params', async ({ request }) => {
const res = await request.get('/search/geo', {
params: { lat: 10.7769 },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects invalid latitude', async ({ request }) => {
const res = await request.get('/search/geo', {
params: { lat: 999, lng: 106.7009, radiusKm: 5 },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects radius exceeding max', async ({ request }) => {
const res = await request.get('/search/geo', {
params: { lat: 10.7769, lng: 106.7009, radiusKm: 200 },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('filters geo results by property type', async ({ request }) => {
const res = await request.get('/search/geo', {
params: {
lat: 10.7769,
lng: 106.7009,
radiusKm: 10,
propertyType: 'APARTMENT',
},
});
if (res.status() === 503) {
test.skip(true, 'Typesense not available');
return;
}
expect(res.status()).toBe(200);
});
});
test.describe('POST /search/reindex — Admin reindex', () => {
test('rejects unauthenticated reindex request', async ({ request }) => {
const res = await request.post('/search/reindex');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
});

View File

@@ -0,0 +1,203 @@
import { test, expect, registerUser } from '../fixtures';
test.describe('Subscriptions API', () => {
let accessToken: string;
test.beforeAll(async ({ request }) => {
const { accessToken: token } = await registerUser(request);
accessToken = token;
});
test.describe('GET /subscriptions/plans — List plans', () => {
test('returns all available subscription plans', async ({ request }) => {
const res = await request.get('/subscriptions/plans');
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBeTruthy();
expect(body.length).toBeGreaterThan(0);
// Verify plan structure
const plan = body[0];
expect(plan).toHaveProperty('tier');
expect(plan).toHaveProperty('name');
expect(plan).toHaveProperty('priceMonthly');
});
test('includes FREE tier in plans', async ({ request }) => {
const res = await request.get('/subscriptions/plans');
const body = await res.json();
const freePlan = body.find((p: { tier: string }) => p.tier === 'FREE');
expect(freePlan).toBeDefined();
});
});
test.describe('GET /subscriptions/plans/:tier — Get specific plan', () => {
test('returns plan details for FREE tier', async ({ request }) => {
const res = await request.get('/subscriptions/plans/FREE');
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.tier).toBe('FREE');
expect(body).toHaveProperty('name');
expect(body).toHaveProperty('maxListings');
});
test('returns plan details for AGENT_PRO tier', async ({ request }) => {
const res = await request.get('/subscriptions/plans/AGENT_PRO');
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.tier).toBe('AGENT_PRO');
});
test('returns 404 for non-existent tier', async ({ request }) => {
const res = await request.get('/subscriptions/plans/NONEXISTENT');
expect(res.ok()).toBeFalsy();
expect([404, 400]).toContain(res.status());
});
});
test.describe('POST /subscriptions — Create subscription', () => {
test('creates a FREE subscription', async ({ request }) => {
const res = await request.post('/subscriptions', {
data: { planTier: 'FREE', billingCycle: 'monthly' },
headers: { Authorization: `Bearer ${accessToken}` },
});
// May succeed or conflict if user already has subscription
expect([201, 409]).toContain(res.status());
if (res.status() === 201) {
const body = await res.json();
expect(body).toHaveProperty('id');
expect(body.planTier).toBe('FREE');
}
});
test('rejects subscription with invalid plan tier', async ({ request }) => {
const res = await request.post('/subscriptions', {
data: { planTier: 'INVALID_TIER', billingCycle: 'monthly' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects subscription with invalid billing cycle', async ({ request }) => {
const res = await request.post('/subscriptions', {
data: { planTier: 'FREE', billingCycle: 'weekly' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('rejects unauthenticated subscription creation', async ({ request }) => {
const res = await request.post('/subscriptions', {
data: { planTier: 'FREE', billingCycle: 'monthly' },
});
expect(res.status()).toBe(401);
});
});
test.describe('GET /subscriptions/quota/:metric — Check quota', () => {
test('returns quota for listings metric', async ({ request }) => {
// Ensure user has a subscription first
await request.post('/subscriptions', {
data: { planTier: 'FREE', billingCycle: 'monthly' },
headers: { Authorization: `Bearer ${accessToken}` },
});
const res = await request.get('/subscriptions/quota/listings', {
headers: { Authorization: `Bearer ${accessToken}` },
});
// 200 if subscription exists, 404 if no subscription
if (res.status() === 200) {
const body = await res.json();
expect(body).toHaveProperty('limit');
expect(body).toHaveProperty('used');
expect(body).toHaveProperty('remaining');
}
});
test('rejects unauthenticated quota check', async ({ request }) => {
const res = await request.get('/subscriptions/quota/listings');
expect(res.status()).toBe(401);
});
});
test.describe('POST /subscriptions/usage — Meter usage', () => {
test('meters usage for authenticated user', async ({ request }) => {
const res = await request.post('/subscriptions/usage', {
data: { metric: 'listings', count: 1 },
headers: { Authorization: `Bearer ${accessToken}` },
});
// 200/201 if subscription exists, 404 if not
expect([200, 201, 404]).toContain(res.status());
});
test('rejects usage with invalid count', async ({ request }) => {
const res = await request.post('/subscriptions/usage', {
data: { metric: 'listings', count: -1 },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
});
test.describe('GET /subscriptions/billing — Billing history', () => {
test('returns billing history for authenticated user', async ({ request }) => {
const res = await request.get('/subscriptions/billing', {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBeTruthy();
});
test('supports pagination', async ({ request }) => {
const res = await request.get('/subscriptions/billing', {
params: { limit: 5, offset: 0 },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status()).toBe(200);
});
test('rejects unauthenticated billing request', async ({ request }) => {
const res = await request.get('/subscriptions/billing');
expect(res.status()).toBe(401);
});
});
test.describe('PUT /subscriptions/upgrade — Upgrade subscription', () => {
test('rejects unauthenticated upgrade', async ({ request }) => {
const res = await request.put('/subscriptions/upgrade', {
data: { newPlanTier: 'AGENT_PRO' },
});
expect(res.status()).toBe(401);
});
});
test.describe('DELETE /subscriptions — Cancel subscription', () => {
test('rejects unauthenticated cancellation', async ({ request }) => {
const res = await request.delete('/subscriptions');
expect(res.status()).toBe(401);
});
});
});

View File

@@ -1,3 +1,4 @@
export { test, expect } from './auth.fixture';
export { createTestUser, registerUser, loginUser } from './auth.fixture';
export type { TokenPair } from './auth.fixture';
export { createTestListing, createListing } from './listings.fixture';

View File

@@ -0,0 +1,43 @@
import { type APIRequestContext } from '@playwright/test';
/** Creates a valid listing payload for E2E tests. */
export function createTestListing(overrides: Record<string, unknown> = {}) {
const suffix = Date.now();
return {
transactionType: 'SALE',
propertyType: 'APARTMENT',
title: `Test Listing ${suffix}`,
description: `E2E test listing description for automated testing ${suffix}`,
address: '123 Nguyễn Huệ',
ward: 'Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
latitude: 10.7769,
longitude: 106.7009,
areaM2: 75,
priceVND: 5000000000,
bedrooms: 2,
bathrooms: 2,
floors: 1,
direction: 'SOUTH',
...overrides,
};
}
/** Creates a listing via the API and returns its id + full response. */
export async function createListing(
request: APIRequestContext,
accessToken: string,
overrides: Record<string, unknown> = {},
) {
const data = createTestListing(overrides);
const res = await request.post('/listings', {
data,
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok()) {
const body = await res.text();
throw new Error(`Create listing failed (${res.status()}): ${body}`);
}
return { listing: await res.json(), data };
}