From 60a0b3c8e17ab9c453d14ce08c55b3f4ca781b84 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 02:23:52 +0700 Subject: [PATCH] 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 --- e2e/api/admin.spec.ts | 152 +++++++++++++++++++++++ e2e/api/listings.spec.ts | 188 ++++++++++++++++++++++++++++ e2e/api/payments.spec.ts | 151 +++++++++++++++++++++++ e2e/api/search.spec.ts | 168 +++++++++++++++++++++++++ e2e/api/subscriptions.spec.ts | 203 +++++++++++++++++++++++++++++++ e2e/fixtures/index.ts | 1 + e2e/fixtures/listings.fixture.ts | 43 +++++++ 7 files changed, 906 insertions(+) create mode 100644 e2e/api/admin.spec.ts create mode 100644 e2e/api/listings.spec.ts create mode 100644 e2e/api/payments.spec.ts create mode 100644 e2e/api/search.spec.ts create mode 100644 e2e/api/subscriptions.spec.ts create mode 100644 e2e/fixtures/listings.fixture.ts diff --git a/e2e/api/admin.spec.ts b/e2e/api/admin.spec.ts new file mode 100644 index 0000000..f7bae06 --- /dev/null +++ b/e2e/api/admin.spec.ts @@ -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); + }); + }); +}); diff --git a/e2e/api/listings.spec.ts b/e2e/api/listings.spec.ts new file mode 100644 index 0000000..062cb2b --- /dev/null +++ b/e2e/api/listings.spec.ts @@ -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); + }); + }); +}); diff --git a/e2e/api/payments.spec.ts b/e2e/api/payments.spec.ts new file mode 100644 index 0000000..9efb331 --- /dev/null +++ b/e2e/api/payments.spec.ts @@ -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()); + }); + }); +}); diff --git a/e2e/api/search.spec.ts b/e2e/api/search.spec.ts new file mode 100644 index 0000000..2f3cfa9 --- /dev/null +++ b/e2e/api/search.spec.ts @@ -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); + }); + }); +}); diff --git a/e2e/api/subscriptions.spec.ts b/e2e/api/subscriptions.spec.ts new file mode 100644 index 0000000..e6db61c --- /dev/null +++ b/e2e/api/subscriptions.spec.ts @@ -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); + }); + }); +}); diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index 2fa911d..07a6b64 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -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'; diff --git a/e2e/fixtures/listings.fixture.ts b/e2e/fixtures/listings.fixture.ts new file mode 100644 index 0000000..7ab6caf --- /dev/null +++ b/e2e/fixtures/listings.fixture.ts @@ -0,0 +1,43 @@ +import { type APIRequestContext } from '@playwright/test'; + +/** Creates a valid listing payload for E2E tests. */ +export function createTestListing(overrides: Record = {}) { + 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 = {}, +) { + 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 }; +}