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:
152
e2e/api/admin.spec.ts
Normal file
152
e2e/api/admin.spec.ts
Normal 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
188
e2e/api/listings.spec.ts
Normal 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
151
e2e/api/payments.spec.ts
Normal 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
168
e2e/api/search.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
e2e/api/subscriptions.spec.ts
Normal file
203
e2e/api/subscriptions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
43
e2e/fixtures/listings.fixture.ts
Normal file
43
e2e/fixtures/listings.fixture.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user