From c0123ac77619e56045503a96a04056d8b8d959b4 Mon Sep 17 00:00:00 2001 From: delta-lynx-89e8 Date: Mon, 23 Feb 2026 06:17:16 -0800 Subject: [PATCH] feat: QA test suite (12 stages, 205 tests) + Chrome DevTools MCP config - Add tests/ directory with 12 bash/curl/jq test stages covering all API endpoints - Add tests/lib/common.sh shared library with assertions and helpers - Add tests/run-all.sh orchestrator script - Update CLAUDE.md with test data reference and DevTools MCP docs - Increase dev rate limit to 5000 for test suite runs - Configure Chrome DevTools MCP in project settings Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 18 +++- CLAUDE.md | 152 +++++++++++++++++--------- server/src/index.ts | 2 +- tests/lib/common.sh | 133 +++++++++++++++++++++++ tests/run-all.sh | 66 ++++++++++++ tests/stage-01-infrastructure.sh | 24 +++++ tests/stage-02-auth.sh | 76 +++++++++++++ tests/stage-03-public.sh | 102 ++++++++++++++++++ tests/stage-04-user-crud.sh | 98 +++++++++++++++++ tests/stage-05-listings.sh | 78 ++++++++++++++ tests/stage-06-offers.sh | 96 +++++++++++++++++ tests/stage-07-rentals.sh | 156 +++++++++++++++++++++++++++ tests/stage-08-subscriptions.sh | 66 ++++++++++++ tests/stage-09-chat-notifications.sh | 80 ++++++++++++++ tests/stage-10-admin.sh | 138 ++++++++++++++++++++++++ tests/stage-11-edge-cases.sh | 102 ++++++++++++++++++ tests/stage-12-client-pages.sh | 44 ++++++++ 17 files changed, 1376 insertions(+), 55 deletions(-) create mode 100644 tests/lib/common.sh create mode 100755 tests/run-all.sh create mode 100755 tests/stage-01-infrastructure.sh create mode 100755 tests/stage-02-auth.sh create mode 100755 tests/stage-03-public.sh create mode 100755 tests/stage-04-user-crud.sh create mode 100755 tests/stage-05-listings.sh create mode 100755 tests/stage-06-offers.sh create mode 100755 tests/stage-07-rentals.sh create mode 100755 tests/stage-08-subscriptions.sh create mode 100755 tests/stage-09-chat-notifications.sh create mode 100755 tests/stage-10-admin.sh create mode 100755 tests/stage-11-edge-cases.sh create mode 100755 tests/stage-12-client-pages.sh diff --git a/.claude/settings.json b/.claude/settings.json index 9267147..6777188 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,10 +4,22 @@ "type": "http", "url": "https://mcp.figma.com/mcp" }, - "chrome-devtools": { + "devtools": { "type": "stdio", - "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics", "--isolated"] + "command": "/opt/homebrew/bin/chrome-devtools-mcp", + "args": [ + "--no-usage-statistics", + "--no-performance-crux", + "--isolated", + "--chromeArg=--user-data-dir=/tmp/chrome-mcp-profile", + "--chromeArg=--no-first-run", + "--chromeArg=--disable-background-networking", + "--chromeArg=--disable-sync", + "--chromeArg=--disable-translate" + ], + "env": { + "CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS": "1" + } } } } diff --git a/CLAUDE.md b/CLAUDE.md index 6ad46e3..fcff48d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,68 +1,118 @@ -# Marketplace Project Notes +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Full-stack marketplace application with two domains: a **classified listings marketplace** (buy/sell items) and a **rental platform** (apartments, houses, cars, bicycles). Includes admin/moderation tools, Stripe payments, real-time chat via Socket.io, and subscription tiers. + +## Architecture + +- **Monorepo** using npm workspaces (`client/` and `server/`) +- **Client**: React 19 + TypeScript + Vite + Tailwind CSS 4 (port 5173) +- **Server**: Express + TypeScript + Prisma ORM + PostgreSQL (port 3000) +- **Real-time**: Socket.io for messaging and notifications +- **Payments**: Stripe (listings, rentals, subscriptions, promotions) +- **Auth**: JWT access tokens (15min) + httpOnly refresh token cookies (7 days), bcrypt passwords +- **Validation**: Zod schemas on server (`server/src/validators/`), TypeScript on client +- **State management**: React Context API (AuthContext), no Redux/Zustand +- **Client path alias**: `@/*` maps to `client/src/*` + +## Commands + +### Development +```bash +docker start marketplace-postgres # Start PostgreSQL (required) +npm run dev # Run client + server together +npm run dev:client # Client only (port 5173) +npm run dev:server # Server only (port 3000) +``` + +### Database +```bash +npm run db:generate --workspace=server # Regenerate Prisma client +npm run db:push --workspace=server # Push schema to DB (no migration) +npm run db:migrate --workspace=server # Create and apply migration +npm run db:seed --workspace=server # Seed test data +npm run db:studio --workspace=server # Open Prisma Studio GUI +``` + +Schema location: `server/prisma/schema.prisma` + +### Build & Lint +```bash +npm run build # Build both client and server +npm run lint # ESLint (client only) +``` + +### Testing (QA Suite) +```bash +bash tests/run-all.sh # Run all 12 stages locally +bash tests/stage-02-auth.sh # Run single stage +BASE_URL=https://marketplace.173.212.212.157.sslip.io bash tests/run-all.sh # Against production +``` +Tests location: `tests/` — 12 stage scripts, `tests/lib/common.sh` shared library. Requires: bash, curl, jq. + +### Seed Data + +| Email | ID | Role | Landlord | Key data | +|-------|-----|------|----------|----------| +| alice.chen@example.com | user-alice | SUPER_ADMIN | No | Listings 01,06,11. Offers, bookings, PRO subscription | +| bob.martinez@example.com | user-bob | ADMIN | No | Listings 08,10,15 | +| carol.nguyen@example.com | user-carol | MODERATOR | No | Listings 02,05,12. Blocked user-david | +| david.kim@example.com | user-david | USER | Yes (verified) | Rentals 01,02,06,07. Listings 04,07,13 | +| eva.johnson@example.com | user-eva | USER | Yes | Rentals 03,04,05. Listings 03,09,14 | + +All passwords: `password123` + +**Key IDs**: listings `listing-01`..`listing-15` | rentals `rental-01`..`rental-07` | bookings `booking-01`..`booking-04` | offers `offer-01`..`offer-10` ## Database: PostgreSQL via Docker -Container name: `marketplace-postgres` -Image: `postgres:17-alpine` -Volume: `marketplace-pgdata` (persistent local data) +Container: `marketplace-postgres` | Image: `postgres:17-alpine` | Volume: `marketplace-pgdata` -### Start database +Connection string: `postgresql://marketplace:marketplace_dev@localhost:5432/marketplace` + +If container was deleted, recreate: ```bash -docker start marketplace-postgres +docker run -d --name marketplace-postgres \ + -e POSTGRES_USER=marketplace -e POSTGRES_PASSWORD=marketplace_dev -e POSTGRES_DB=marketplace \ + -p 5432:5432 -v marketplace-pgdata:/var/lib/postgresql/data \ + --restart unless-stopped postgres:17-alpine ``` -### Stop database (frees memory) -```bash -docker stop marketplace-postgres -``` +## Key Server Patterns -### Check status -```bash -docker ps -f name=marketplace-postgres -``` +- **Routes**: `server/src/routes/` — RESTful endpoints, admin routes nested under `routes/admin/` +- **Middleware**: `authenticate` (required auth), `optionalAuth` (anonymous OK), `requireRole` (role check), `checkBanned`, `validate` (Zod) +- **Prisma client**: imported from `server/src/config/database.ts` +- **Environment config**: `server/src/config/env.ts` +- **File uploads**: Multer to `server/uploads/`, served statically +- **User roles**: USER, MODERATOR, ADMIN, SUPER_ADMIN — role hierarchy enforced via `requireRole` middleware -### Connection string -``` -postgresql://marketplace:marketplace_dev@localhost:5432/marketplace -``` +## Key Client Patterns -### If container was deleted, recreate: -```bash -docker run -d \ - --name marketplace-postgres \ - -e POSTGRES_USER=marketplace \ - -e POSTGRES_PASSWORD=marketplace_dev \ - -e POSTGRES_DB=marketplace \ - -p 5432:5432 \ - -v marketplace-pgdata:/var/lib/postgresql/data \ - --restart unless-stopped \ - postgres:17-alpine -``` +- **Router**: `client/src/router.tsx` — React Router v7 with `createBrowserRouter` +- **Auth guards**: `` wraps protected routes, `` checks roles +- **API client**: `client/src/api/client.ts` — fetch wrapper that handles auth headers and token refresh +- **UI components**: `client/src/components/ui/` — Button, Input, Modal, Card, Badge, DataTable, etc. +- **Pages organized by domain**: root pages, `pages/admin/`, `pages/landlord/` -Data persists in the `marketplace-pgdata` Docker volume even if the container is removed. +## Chrome DevTools MCP (Browser Testing) -## Running the app +Chrome DevTools MCP is configured for automated browser testing. It launches an isolated, telemetry-free Chrome instance. -1. Start database: `docker start marketplace-postgres` -2. Server: `npm run dev:server` (port 3000) -3. Client: `npm run dev:client` (port 5173) -4. Or both: `npm run dev` +- **Config location**: `~/.claude.json` → `mcpServers.chrome-devtools` (NOT `~/.claude/settings.json`) +- **Binary**: `/opt/homebrew/bin/chrome-devtools-mcp` (installed globally via npm) +- **Chrome profile**: `/tmp/chrome-mcp-profile` (isolated, no Google account) +- **Key flags**: `--isolated` (auto-launches Chrome), `--disable-sync`, `--disable-background-networking` +- **Usage**: Available automatically when Claude Code starts. Use `take_snapshot`, `click`, `fill`, `navigate_page`, `take_screenshot` etc. +- **If MCP won't start**: Kill zombie processes (`pkill -f chrome-devtools-mcp`), remove lock (`rm /tmp/chrome-mcp-profile/SingletonLock`), restart Claude Code -## Seed data +## Key Env Vars (server/.env) -```bash -cd server && npx tsx prisma/seed.ts -``` - -Test users (all password: `password123`): -- alice.chen@example.com -- bob.smith@example.com -- carol.jones@example.com -- david.wilson@example.com -- eva.martinez@example.com - -## Key env vars (server/.env) - -- `DATABASE_URL` — PostgreSQL connection +- `DATABASE_URL` — PostgreSQL connection string +- `JWT_SECRET` / `JWT_REFRESH_SECRET` — Token signing - `GOOGLE_MAPS_API_KEY` — Location autocomplete - `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` — Payments (optional for dev) +- `CLIENT_URL` — CORS origin (default: http://localhost:5173) diff --git a/server/src/index.ts b/server/src/index.ts index ac0a128..22e3b75 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -49,7 +49,7 @@ app.use('/api/rental-payments/webhook', express.raw({ type: 'application/json' } app.use(express.json()); // Rate limiting -const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 50 }); +const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: env.NODE_ENV === 'production' ? 50 : 5000 }); app.use('/api/auth/login', authLimiter); app.use('/api/auth/register', authLimiter); diff --git a/tests/lib/common.sh b/tests/lib/common.sh new file mode 100644 index 0000000..fe42b7f --- /dev/null +++ b/tests/lib/common.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# tests/lib/common.sh — Shared testing utilities + +BASE_URL="${BASE_URL:-http://localhost:3000}" +CLIENT_URL="${CLIENT_URL:-http://localhost:5173}" +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# --- HTTP helpers --- +api_call() { + local method="$1" path="$2" token="$3" data="$4" + local args=(-s -w '\n%{http_code}' -X "$method") + [[ -n "$token" ]] && args+=(-H "Authorization: Bearer $token") + [[ -n "$data" ]] && args+=(-H "Content-Type: application/json" -d "$data") + curl "${args[@]}" "${BASE_URL}${path}" 2>/dev/null +} + +get_body() { echo "$1" | sed '$ d'; } +get_status() { echo "$1" | tail -n1; } + +# --- Login helper (returns token, retries on rate limit) --- +login_as() { + local email="$1" password="${2:-password123}" + local result body status attempt + for attempt in 1 2 3 4 5; do + result=$(api_call POST "/api/auth/login" "" "{\"email\":\"$email\",\"password\":\"$password\"}") + status=$(get_status "$result") + if [[ "$status" == "429" || "$status" == "500" || "$status" == "503" ]]; then + sleep "$attempt" + continue + fi + body=$(get_body "$result") + echo "$body" | jq -r '.accessToken // empty' 2>/dev/null + return + done + echo "" +} + +# --- Assertions --- +assert_status() { + local name="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + printf " ${GREEN}PASS${NC} %s (HTTP %s)\n" "$name" "$actual" + ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} %s (expected %s, got %s)\n" "$name" "$expected" "$actual" + ((FAIL_COUNT++)) + fi +} + +assert_json() { + local name="$1" body="$2" field="$3" expected="$4" + local actual + actual=$(echo "$body" | jq -r "$field" 2>/dev/null) + if [[ "$actual" == "$expected" ]]; then + printf " ${GREEN}PASS${NC} %s (%s = %s)\n" "$name" "$field" "$actual" + ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} %s (%s: expected '%s', got '%s')\n" "$name" "$field" "$expected" "$actual" + ((FAIL_COUNT++)) + fi +} + +assert_json_exists() { + local name="$1" body="$2" field="$3" + local actual + actual=$(echo "$body" | jq -r "$field" 2>/dev/null) + if [[ -n "$actual" && "$actual" != "null" ]]; then + printf " ${GREEN}PASS${NC} %s (%s exists)\n" "$name" "$field" + ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} %s (%s is null/missing)\n" "$name" "$field" + ((FAIL_COUNT++)) + fi +} + +assert_json_array_min() { + local name="$1" body="$2" field="$3" min="${4:-1}" + local length + length=$(echo "$body" | jq "$field | length" 2>/dev/null) + if [[ "$length" -ge "$min" ]] 2>/dev/null; then + printf " ${GREEN}PASS${NC} %s (%s items)\n" "$name" "$length" + ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} %s (expected >= %s items, got %s)\n" "$name" "$min" "$length" + ((FAIL_COUNT++)) + fi +} + +assert_contains() { + local name="$1" body="$2" substring="$3" + if echo "$body" | grep -q "$substring"; then + printf " ${GREEN}PASS${NC} %s (contains '%s')\n" "$name" "$substring" + ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} %s (missing '%s')\n" "$name" "$substring" + ((FAIL_COUNT++)) + fi +} + +skip_test() { + local name="$1" reason="$2" + printf " ${YELLOW}SKIP${NC} %s (%s)\n" "$name" "$reason" + ((SKIP_COUNT++)) +} + +# --- Stage header/summary --- +stage_header() { + echo "" + printf "${CYAN}${BOLD}════════════════════════════════════════${NC}\n" + printf "${CYAN}${BOLD} %s${NC}\n" "$1" + printf "${CYAN}${BOLD}════════════════════════════════════════${NC}\n" +} + +print_summary() { + local name="$1" + echo "" + printf "${BOLD}--- %s Results ---${NC}\n" "$name" + printf " ${GREEN}Passed: %d${NC}\n" "$PASS_COUNT" + [[ $FAIL_COUNT -gt 0 ]] && printf " ${RED}Failed: %d${NC}\n" "$FAIL_COUNT" + [[ $SKIP_COUNT -gt 0 ]] && printf " ${YELLOW}Skipped: %d${NC}\n" "$SKIP_COUNT" + printf " Total: %d\n" "$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))" + [[ $FAIL_COUNT -gt 0 ]] && return 1 + return 0 +} diff --git a/tests/run-all.sh b/tests/run-all.sh new file mode 100755 index 0000000..1cf92a0 --- /dev/null +++ b/tests/run-all.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +FAILED_STAGES=() +PASSED_STAGES=() +TOTAL_STAGES=0 + +run_stage() { + local script="$1" name="$2" + ((TOTAL_STAGES++)) + sleep 1 # Brief pause between stages to avoid overwhelming the server + if bash "$SCRIPT_DIR/$script"; then + PASSED_STAGES+=("$name") + else + FAILED_STAGES+=("$name") + fi +} + +echo "" +printf "${BOLD}╔══════════════════════════════════════════╗${NC}\n" +printf "${BOLD}║ Marketplace QA Test Suite ║${NC}\n" +printf "${BOLD}╚══════════════════════════════════════════╝${NC}\n" +echo " API: ${BASE_URL:-http://localhost:3000}" +echo " Client: ${CLIENT_URL:-http://localhost:5173}" +echo "" + +run_stage "stage-01-infrastructure.sh" "Infrastructure" +run_stage "stage-02-auth.sh" "Authentication" +run_stage "stage-03-public.sh" "Public Endpoints" +run_stage "stage-04-user-crud.sh" "User CRUD" +run_stage "stage-05-listings.sh" "Listings Workflow" +run_stage "stage-06-offers.sh" "Offers Workflow" +run_stage "stage-07-rentals.sh" "Rental Workflow" +run_stage "stage-08-subscriptions.sh" "Subscriptions" +run_stage "stage-09-chat-notifications.sh" "Chat & Notifications" +run_stage "stage-10-admin.sh" "Admin Operations" +run_stage "stage-11-edge-cases.sh" "Edge Cases" + +# Stage 12 only if client running +CLIENT_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${CLIENT_URL:-http://localhost:5173}" 2>/dev/null) +if [[ "$CLIENT_STATUS" == "200" ]]; then + run_stage "stage-12-client-pages.sh" "Client Pages" +else + printf "\n${YELLOW}SKIP: Client not running — Stage 12 skipped${NC}\n" +fi + +# Final report +echo "" +printf "${BOLD}╔══════════════════════════════════════════╗${NC}\n" +printf "${BOLD}║ FINAL RESULTS ║${NC}\n" +printf "${BOLD}╚══════════════════════════════════════════╝${NC}\n" +printf " Stages passed: ${GREEN}%d${NC} / %d\n" "${#PASSED_STAGES[@]}" "$TOTAL_STAGES" + +if [[ ${#FAILED_STAGES[@]} -gt 0 ]]; then + printf " ${RED}Failed stages:${NC}\n" + for s in "${FAILED_STAGES[@]}"; do + printf " ${RED}✗ %s${NC}\n" "$s" + done + exit 1 +else + printf " ${GREEN}ALL STAGES PASSED${NC}\n" + exit 0 +fi diff --git a/tests/stage-01-infrastructure.sh b/tests/stage-01-infrastructure.sh new file mode 100755 index 0000000..d6629e4 --- /dev/null +++ b/tests/stage-01-infrastructure.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 1: Infrastructure" + +# 1.1 Health check +r=$(api_call GET "/api/health") +assert_status "1.1 Health endpoint" "200" "$(get_status "$r")" +assert_json "1.1 Health status" "$(get_body "$r")" ".status" "ok" + +# 1.2 Listings endpoint (proves DB + Prisma) +r=$(api_call GET "/api/listings") +assert_status "1.2 Listings endpoint" "200" "$(get_status "$r")" + +# 1.3 Rentals endpoint +r=$(api_call GET "/api/rentals") +assert_status "1.3 Rentals endpoint" "200" "$(get_status "$r")" + +# 1.4 Subscription tiers (proves platform config) +r=$(api_call GET "/api/subscriptions/tiers") +assert_status "1.4 Subscription tiers" "200" "$(get_status "$r")" +assert_json_array_min "1.4 Tiers count" "$(get_body "$r")" "." 3 + +print_summary "Stage 1: Infrastructure" diff --git a/tests/stage-02-auth.sh b/tests/stage-02-auth.sh new file mode 100755 index 0000000..66821a6 --- /dev/null +++ b/tests/stage-02-auth.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 2: Authentication" + +TEST_EMAIL="qa-test-$(date +%s)@example.com" +TEST_USER_ID="" +ALICE_TOKEN="" + +cleanup() { + : # test user stays (unique email, no harm) +} +trap cleanup EXIT + +# 2.1 Register new user +r=$(api_call POST "/api/auth/register" "" "{\"fullName\":\"QA Test User\",\"email\":\"$TEST_EMAIL\",\"password\":\"TestPass123\"}") +assert_status "2.1 Register valid user" "201" "$(get_status "$r")" +b=$(get_body "$r") +assert_json_exists "2.1 Token returned" "$b" ".accessToken" +TEST_TOKEN=$(echo "$b" | jq -r '.accessToken // empty') +TEST_USER_ID=$(echo "$b" | jq -r '.user.id // empty') + +# 2.2 Register duplicate email +r=$(api_call POST "/api/auth/register" "" "{\"fullName\":\"Dup\",\"email\":\"$TEST_EMAIL\",\"password\":\"TestPass123\"}") +assert_status "2.2 Duplicate email" "409" "$(get_status "$r")" + +# 2.3 Register missing password +r=$(api_call POST "/api/auth/register" "" "{\"fullName\":\"No Pass\",\"email\":\"nopass@example.com\"}") +assert_status "2.3 Missing password" "400" "$(get_status "$r")" + +# 2.4 Register short password +r=$(api_call POST "/api/auth/register" "" "{\"fullName\":\"Short\",\"email\":\"short@example.com\",\"password\":\"123\"}") +assert_status "2.4 Short password" "400" "$(get_status "$r")" + +# 2.5 Login valid (Alice - SUPER_ADMIN) +r=$(api_call POST "/api/auth/login" "" '{"email":"alice.chen@example.com","password":"password123"}') +assert_status "2.5 Login Alice" "200" "$(get_status "$r")" +b=$(get_body "$r") +assert_json "2.5 Alice role" "$b" ".user.role" "SUPER_ADMIN" +ALICE_TOKEN=$(echo "$b" | jq -r '.accessToken') + +# 2.6 Login wrong password +r=$(api_call POST "/api/auth/login" "" '{"email":"alice.chen@example.com","password":"wrong"}') +assert_status "2.6 Wrong password" "401" "$(get_status "$r")" + +# 2.7 Login nonexistent email +r=$(api_call POST "/api/auth/login" "" '{"email":"nobody@example.com","password":"pass"}') +assert_status "2.7 Nonexistent email" "401" "$(get_status "$r")" + +# 2.8 GET /me with valid token +r=$(api_call GET "/api/auth/me" "$ALICE_TOKEN") +assert_status "2.8 GET /me valid" "200" "$(get_status "$r")" +assert_json "2.8 Correct user" "$(get_body "$r")" ".user.email" "alice.chen@example.com" + +# 2.9 GET /me no token +r=$(api_call GET "/api/auth/me") +assert_status "2.9 GET /me no auth" "401" "$(get_status "$r")" + +# 2.10 GET /me garbage token +r=$(api_call GET "/api/auth/me" "invalidtoken123") +assert_status "2.10 GET /me bad token" "401" "$(get_status "$r")" + +# 2.11 Logout +r=$(api_call POST "/api/auth/logout" "$TEST_TOKEN") +assert_status "2.11 Logout" "200" "$(get_status "$r")" + +# 2.12 Login all seed users +for user in "bob.martinez@example.com:ADMIN" "carol.nguyen@example.com:MODERATOR" "david.kim@example.com:USER" "eva.johnson@example.com:USER"; do + email="${user%%:*}" + role="${user##*:}" + r=$(api_call POST "/api/auth/login" "" "{\"email\":\"$email\",\"password\":\"password123\"}") + assert_status "2.12 Login $email" "200" "$(get_status "$r")" + assert_json "2.12 Role $email" "$(get_body "$r")" ".user.role" "$role" +done + +print_summary "Stage 2: Authentication" diff --git a/tests/stage-03-public.sh b/tests/stage-03-public.sh new file mode 100755 index 0000000..cd949be --- /dev/null +++ b/tests/stage-03-public.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 3: Public Endpoints" + +# 3.1 List all listings +r=$(api_call GET "/api/listings") +assert_status "3.1 GET /listings" "200" "$(get_status "$r")" +b=$(get_body "$r") +assert_json_array_min "3.1 Has listings" "$b" ".data" 1 + +# 3.2 Category filter +r=$(api_call GET "/api/listings?category=ELECTRONICS") +b=$(get_body "$r") +assert_status "3.2 Filter ELECTRONICS" "200" "$(get_status "$r")" +count=$(echo "$b" | jq '[.data[] | select(.category != "ELECTRONICS")] | length') +assert_json "3.2 All are ELECTRONICS" "$b" '[.data[] | select(.category != "ELECTRONICS")] | length' "0" + +# 3.3 Search +r=$(api_call GET "/api/listings?search=MacBook") +assert_status "3.3 Search MacBook" "200" "$(get_status "$r")" +b=$(get_body "$r") +total=$(echo "$b" | jq '.total') +if [[ "$total" -ge 1 ]]; then + printf " ${GREEN}PASS${NC} 3.3 Found %s results\n" "$total"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 3.3 Expected >= 1, got %s\n" "$total"; ((FAIL_COUNT++)) +fi + +# 3.4 Sort by price ascending +r=$(api_call GET "/api/listings?sort=price_asc") +b=$(get_body "$r") +first=$(echo "$b" | jq '.data[0].price') +last=$(echo "$b" | jq '.data[-1].price') +if awk "BEGIN{exit !($first <= $last)}"; then + printf " ${GREEN}PASS${NC} 3.4 Price sort asc (%s <= %s)\n" "$first" "$last"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 3.4 Price sort asc (%s > %s)\n" "$first" "$last"; ((FAIL_COUNT++)) +fi + +# 3.5 Pagination +r=$(api_call GET "/api/listings?page=1&pageSize=2") +b=$(get_body "$r") +assert_status "3.5 Pagination" "200" "$(get_status "$r")" +len=$(echo "$b" | jq '.data | length') +if [[ "$len" -le 2 ]]; then + printf " ${GREEN}PASS${NC} 3.5 Page size respected (%s items)\n" "$len"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 3.5 Page size not respected (%s items)\n" "$len"; ((FAIL_COUNT++)) +fi + +# 3.6 Single listing +r=$(api_call GET "/api/listings/listing-01") +assert_status "3.6 GET listing-01" "200" "$(get_status "$r")" +assert_contains "3.6 Has MacBook" "$(get_body "$r")" "MacBook" + +# 3.7 Non-existent listing +r=$(api_call GET "/api/listings/nonexistent-id") +assert_status "3.7 404 on missing" "404" "$(get_status "$r")" + +# 3.8 List rentals +r=$(api_call GET "/api/rentals") +assert_status "3.8 GET /rentals" "200" "$(get_status "$r")" +assert_json_array_min "3.8 Has rentals" "$(get_body "$r")" ".data" 1 + +# 3.9 Rental category filter +r=$(api_call GET "/api/rentals?category=APARTMENT") +assert_status "3.9 Filter APARTMENT" "200" "$(get_status "$r")" + +# 3.10 Rental search +r=$(api_call GET "/api/rentals?search=Tesla") +b=$(get_body "$r") +assert_status "3.10 Search Tesla" "200" "$(get_status "$r")" + +# 3.11 Rental sort + period +r=$(api_call GET "/api/rentals?sort=price_asc&periodType=DAILY") +assert_status "3.11 Rental sort" "200" "$(get_status "$r")" + +# 3.12 Single rental +r=$(api_call GET "/api/rentals/rental-01") +assert_status "3.12 GET rental-01" "200" "$(get_status "$r")" +assert_json_exists "3.12 Has landlord" "$(get_body "$r")" ".landlord.fullName" + +# 3.13 Availability (public, no auth) +r=$(api_call GET "/api/rentals/rental-01/availability") +assert_status "3.13 Availability" "200" "$(get_status "$r")" + +# 3.14 Landlord reviews (public) +r=$(api_call GET "/api/rental-reviews/landlord/user-david") +assert_status "3.14 Landlord reviews" "200" "$(get_status "$r")" + +# 3.15 Public user profile +r=$(api_call GET "/api/users/user-alice") +assert_status "3.15 Public profile" "200" "$(get_status "$r")" +assert_json "3.15 Correct name" "$(get_body "$r")" ".fullName" "Alice Chen" + +# 3.16 Subscription tiers +r=$(api_call GET "/api/subscriptions/tiers") +assert_status "3.16 Tiers list" "200" "$(get_status "$r")" +assert_json_array_min "3.16 Has 3 tiers" "$(get_body "$r")" "." 3 + +print_summary "Stage 3: Public Endpoints" diff --git a/tests/stage-04-user-crud.sh b/tests/stage-04-user-crud.sh new file mode 100755 index 0000000..226f17a --- /dev/null +++ b/tests/stage-04-user-crud.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 4: User CRUD" + +EVA_TOKEN=$(login_as "eva.johnson@example.com") +if [[ -z "$EVA_TOKEN" ]]; then echo "FATAL: Cannot login as Eva"; exit 1; fi + +ORIGINAL_BIO="" +cleanup() { + # Restore Eva's bio if changed + if [[ -n "$ORIGINAL_BIO" ]]; then + api_call PUT "/api/users/profile" "$EVA_TOKEN" "{\"bio\":\"$ORIGINAL_BIO\"}" >/dev/null 2>&1 + fi + # Unblock Bob if blocked + api_call DELETE "/api/users/user-bob/block" "$EVA_TOKEN" >/dev/null 2>&1 +} +trap cleanup EXIT + +# 4.1 Get own profile +r=$(api_call GET "/api/users/profile" "$EVA_TOKEN") +assert_status "4.1 GET /profile" "200" "$(get_status "$r")" +b=$(get_body "$r") +assert_json "4.1 Correct name" "$b" ".fullName" "Eva Johnson" +ORIGINAL_BIO=$(echo "$b" | jq -r '.bio // ""') + +# 4.2 Update bio +r=$(api_call PUT "/api/users/profile" "$EVA_TOKEN" '{"bio":"QA test bio update"}') +assert_status "4.2 Update bio" "200" "$(get_status "$r")" +assert_json "4.2 Bio updated" "$(get_body "$r")" ".bio" "QA test bio update" + +# 4.3 Get settings +r=$(api_call GET "/api/users/settings" "$EVA_TOKEN") +assert_status "4.3 GET /settings" "200" "$(get_status "$r")" + +# 4.4 Update settings +r=$(api_call PUT "/api/users/settings" "$EVA_TOKEN" '{"showOnlineStatus":false}') +assert_status "4.4 Update settings" "200" "$(get_status "$r")" +# Restore +api_call PUT "/api/users/settings" "$EVA_TOKEN" '{"showOnlineStatus":true}' >/dev/null + +# 4.5 Sessions +r=$(api_call GET "/api/users/sessions" "$EVA_TOKEN") +assert_status "4.5 GET /sessions" "200" "$(get_status "$r")" + +# 4.6 Change password (use temp user, not seed user — safer for idempotency) +PW_EMAIL="qa-pw-$(date +%s)@example.com" +r=$(api_call POST "/api/auth/register" "" "{\"fullName\":\"PW Test\",\"email\":\"$PW_EMAIL\",\"password\":\"OrigPass123\"}") +PW_TOKEN=$(get_body "$r" | jq -r '.accessToken // empty') +if [[ -n "$PW_TOKEN" ]]; then + r=$(api_call PUT "/api/users/password" "$PW_TOKEN" '{"currentPassword":"OrigPass123","newPassword":"NewPass456"}') + assert_status "4.6 Change password" "200" "$(get_status "$r")" + # Verify login with new password + PW_TOKEN2=$(login_as "$PW_EMAIL" "NewPass456") + if [[ -n "$PW_TOKEN2" ]]; then + printf " ${GREEN}PASS${NC} 4.6 Login with new password works\n"; ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} 4.6 Cannot login with new password\n"; ((FAIL_COUNT++)) + fi +else + skip_test "4.6 Change password" "Could not register temp user" +fi + +# 4.7 Wrong current password +r=$(api_call PUT "/api/users/password" "$EVA_TOKEN" '{"currentPassword":"wrongpw","newPassword":"anything"}') +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "401" ]]; then + printf " ${GREEN}PASS${NC} 4.7 Wrong password rejected (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 4.7 Expected 400/401, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 4.8 Toggle favorite +r=$(api_call POST "/api/listings/listing-01/favorite" "$EVA_TOKEN") +assert_status "4.8 Toggle favorite" "200" "$(get_status "$r")" + +# 4.9 Get favorites +r=$(api_call GET "/api/listings/favorites" "$EVA_TOKEN") +assert_status "4.9 GET /favorites" "200" "$(get_status "$r")" + +# 4.10 Block user +r=$(api_call POST "/api/users/user-bob/block" "$EVA_TOKEN") +assert_status "4.10 Block Bob" "200" "$(get_status "$r")" + +# 4.11 Cannot block self +r=$(api_call POST "/api/users/user-eva/block" "$EVA_TOKEN") +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "409" ]]; then + printf " ${GREEN}PASS${NC} 4.11 Cannot block self (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 4.11 Expected 400/409, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 4.12 Unblock +r=$(api_call DELETE "/api/users/user-bob/block" "$EVA_TOKEN") +assert_status "4.12 Unblock Bob" "200" "$(get_status "$r")" + +print_summary "Stage 4: User CRUD" diff --git a/tests/stage-05-listings.sh b/tests/stage-05-listings.sh new file mode 100755 index 0000000..4ac545a --- /dev/null +++ b/tests/stage-05-listings.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 5: Listings Workflow" + +BOB_TOKEN=$(login_as "bob.martinez@example.com") +ALICE_TOKEN=$(login_as "alice.chen@example.com") +if [[ -z "$BOB_TOKEN" || -z "$ALICE_TOKEN" ]]; then echo "FATAL: Cannot login"; exit 1; fi + +LISTING_ID="" +cleanup() { + # Delete test listing if created + if [[ -n "$LISTING_ID" ]]; then + api_call DELETE "/api/listings/$LISTING_ID" "$BOB_TOKEN" >/dev/null 2>&1 + fi +} +trap cleanup EXIT + +# 5.1 Create listing +r=$(api_call POST "/api/listings" "$BOB_TOKEN" '{"title":"QA Test Listing","description":"A test listing created by automated QA suite","price":49.99,"category":"ELECTRONICS","condition":"LIKE_NEW","location":"Test City, QA"}') +assert_status "5.1 Create listing" "201" "$(get_status "$r")" +b=$(get_body "$r") +LISTING_ID=$(echo "$b" | jq -r '.id // empty') +assert_json "5.1 Status DRAFT" "$b" ".status" "DRAFT" + +# 5.2 Activate listing +r=$(api_call POST "/api/listings/$LISTING_ID/activate" "$BOB_TOKEN") +assert_status "5.2 Activate listing" "200" "$(get_status "$r")" +b=$(get_body "$r") +# autoApprove=true → ACTIVE; else PENDING_REVIEW +status=$(echo "$b" | jq -r '.status') +if [[ "$status" == "ACTIVE" || "$status" == "PENDING_REVIEW" ]]; then + printf " ${GREEN}PASS${NC} 5.2 Status is %s\n" "$status"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 5.2 Unexpected status: %s\n" "$status"; ((FAIL_COUNT++)) +fi + +# 5.3 Get own listings +r=$(api_call GET "/api/listings/mine" "$BOB_TOKEN") +assert_status "5.3 GET /mine" "200" "$(get_status "$r")" +assert_contains "5.3 Contains test listing" "$(get_body "$r")" "QA Test Listing" + +# 5.4 Update listing +r=$(api_call PUT "/api/listings/$LISTING_ID" "$BOB_TOKEN" '{"title":"QA Test Listing Updated","price":39.99}') +assert_status "5.4 Update listing" "200" "$(get_status "$r")" +assert_json "5.4 Title updated" "$(get_body "$r")" ".title" "QA Test Listing Updated" + +# 5.5 Get single listing +r=$(api_call GET "/api/listings/$LISTING_ID") +assert_status "5.5 GET single" "200" "$(get_status "$r")" +assert_json "5.5 Price updated" "$(get_body "$r")" ".price" "39.99" + +# 5.6 Cannot edit someone else's listing +r=$(api_call PUT "/api/listings/$LISTING_ID" "$ALICE_TOKEN" '{"title":"Hacked"}') +assert_status "5.6 Edit other's listing" "403" "$(get_status "$r")" + +# 5.7 Cannot delete someone else's listing +r=$(api_call DELETE "/api/listings/$LISTING_ID" "$ALICE_TOKEN") +assert_status "5.7 Delete other's listing" "403" "$(get_status "$r")" + +# 5.8 Delete own listing +r=$(api_call DELETE "/api/listings/$LISTING_ID" "$BOB_TOKEN") +assert_status "5.8 Delete own listing" "200" "$(get_status "$r")" +LISTING_ID="" # Cleared for cleanup + +# 5.9 Create with missing title +r=$(api_call POST "/api/listings" "$BOB_TOKEN" '{"description":"desc that is long enough","price":10,"category":"OTHER","condition":"NEW","location":"Here"}') +assert_status "5.9 Missing title" "400" "$(get_status "$r")" + +# 5.10 Create with negative price +r=$(api_call POST "/api/listings" "$BOB_TOKEN" '{"title":"Bad Price","description":"desc that is long enough","price":-5,"category":"OTHER","condition":"NEW","location":"Here"}') +assert_status "5.10 Negative price" "400" "$(get_status "$r")" + +# 5.11 Sold items (Alice has sold items) +r=$(api_call GET "/api/listings/sold" "$ALICE_TOKEN") +assert_status "5.11 GET /sold" "200" "$(get_status "$r")" + +print_summary "Stage 5: Listings Workflow" diff --git a/tests/stage-06-offers.sh b/tests/stage-06-offers.sh new file mode 100755 index 0000000..3c0184a --- /dev/null +++ b/tests/stage-06-offers.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 6: Offers Workflow" + +ALICE_TOKEN=$(login_as "alice.chen@example.com") +EVA_TOKEN=$(login_as "eva.johnson@example.com") +DAVID_TOKEN=$(login_as "david.kim@example.com") +if [[ -z "$ALICE_TOKEN" || -z "$EVA_TOKEN" || -z "$DAVID_TOKEN" ]]; then echo "FATAL: Cannot login"; exit 1; fi + +# Create a fresh listing for offer tests (so we don't mutate seed data) +TEST_LISTING_ID="" +OFFER_ID="" +OFFER2_ID="" + +cleanup() { + # Cancel any pending offers + [[ -n "$OFFER_ID" ]] && api_call DELETE "/api/offers/$OFFER_ID" "$EVA_TOKEN" >/dev/null 2>&1 + [[ -n "$OFFER2_ID" ]] && api_call DELETE "/api/offers/$OFFER2_ID" "$EVA_TOKEN" >/dev/null 2>&1 + # Delete test listing + [[ -n "$TEST_LISTING_ID" ]] && api_call DELETE "/api/listings/$TEST_LISTING_ID" "$ALICE_TOKEN" >/dev/null 2>&1 +} +trap cleanup EXIT + +# Setup: create test listing as Alice +r=$(api_call POST "/api/listings" "$ALICE_TOKEN" '{"title":"Offer Test Item","description":"A listing created for testing offers workflow","price":500,"category":"ELECTRONICS","condition":"NEW","location":"QA City"}') +TEST_LISTING_ID=$(get_body "$r" | jq -r '.id // empty') +api_call POST "/api/listings/$TEST_LISTING_ID/activate" "$ALICE_TOKEN" >/dev/null + +# 6.1 Create offer (Eva → Alice's listing) +r=$(api_call POST "/api/offers" "$EVA_TOKEN" "{\"amount\":400,\"message\":\"QA test offer\",\"listingId\":\"$TEST_LISTING_ID\"}") +assert_status "6.1 Create offer" "201" "$(get_status "$r")" +b=$(get_body "$r") +OFFER_ID=$(echo "$b" | jq -r '.id // empty') +assert_json "6.1 Status PENDING" "$b" ".status" "PENDING" + +# 6.2 Duplicate offer (same buyer, same listing) +r=$(api_call POST "/api/offers" "$EVA_TOKEN" "{\"amount\":450,\"listingId\":\"$TEST_LISTING_ID\"}") +assert_status "6.2 Duplicate offer" "409" "$(get_status "$r")" + +# 6.3 Get sent offers +r=$(api_call GET "/api/offers" "$EVA_TOKEN") +assert_status "6.3 GET offers (sent)" "200" "$(get_status "$r")" + +# 6.4 Get received offers (as Alice) +r=$(api_call GET "/api/offers" "$ALICE_TOKEN") +assert_status "6.4 GET offers (received)" "200" "$(get_status "$r")" + +# 6.5 Counter offer (Alice counters) +r=$(api_call PATCH "/api/offers/$OFFER_ID" "$ALICE_TOKEN" '{"status":"COUNTERED","counterAmount":450}') +assert_status "6.5 Counter offer" "200" "$(get_status "$r")" +assert_json "6.5 Status COUNTERED" "$(get_body "$r")" ".status" "COUNTERED" + +# 6.6 Decline counter (Eva declines) +r=$(api_call PATCH "/api/offers/$OFFER_ID" "$EVA_TOKEN" '{"status":"DECLINED"}') +assert_status "6.6 Decline counter" "200" "$(get_status "$r")" +OFFER_ID="" # No longer pending + +# 6.7 Self-offer prevention +r=$(api_call POST "/api/offers" "$ALICE_TOKEN" "{\"amount\":100,\"listingId\":\"$TEST_LISTING_ID\"}") +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "403" ]]; then + printf " ${GREEN}PASS${NC} 6.7 Self-offer rejected (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 6.7 Expected 400/403, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 6.8 New offer then accept (full cycle) +r=$(api_call POST "/api/offers" "$EVA_TOKEN" "{\"amount\":480,\"listingId\":\"$TEST_LISTING_ID\"}") +OFFER2_ID=$(get_body "$r" | jq -r '.id // empty') +assert_status "6.8 New offer" "201" "$(get_status "$r")" + +r=$(api_call PATCH "/api/offers/$OFFER2_ID" "$ALICE_TOKEN" '{"status":"ACCEPTED"}') +assert_status "6.8 Accept offer" "200" "$(get_status "$r")" +assert_json "6.8 Status ACCEPTED" "$(get_body "$r")" ".status" "ACCEPTED" +OFFER2_ID="" # Completed, no need to cancel +TEST_LISTING_ID="" # Now SOLD, will 404 on delete + +# 6.9 Offer on non-active listing (DRAFT) +r=$(api_call POST "/api/offers" "$EVA_TOKEN" '{"amount":300,"listingId":"listing-15"}') +assert_status "6.9 Offer on DRAFT" "400" "$(get_status "$r")" + +# 6.10 Blocked user offer: Carol blocked David +r=$(api_call POST "/api/offers" "$DAVID_TOKEN" '{"amount":200,"listingId":"listing-02"}') +s=$(get_status "$r") +if [[ "$s" == "403" || "$s" == "400" ]]; then + printf " ${GREEN}PASS${NC} 6.10 Blocked user rejected (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 6.10 Expected 403/400, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 6.11 Offer on non-existent listing +r=$(api_call POST "/api/offers" "$EVA_TOKEN" '{"amount":100,"listingId":"fake-listing-id"}') +assert_status "6.11 Non-existent listing" "404" "$(get_status "$r")" + +print_summary "Stage 6: Offers Workflow" diff --git a/tests/stage-07-rentals.sh b/tests/stage-07-rentals.sh new file mode 100755 index 0000000..e3c7819 --- /dev/null +++ b/tests/stage-07-rentals.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 7: Rental Workflow" + +DAVID_TOKEN=$(login_as "david.kim@example.com") +ALICE_TOKEN=$(login_as "alice.chen@example.com") +CAROL_TOKEN=$(login_as "carol.nguyen@example.com") +if [[ -z "$DAVID_TOKEN" || -z "$ALICE_TOKEN" || -z "$CAROL_TOKEN" ]]; then echo "FATAL: Cannot login"; exit 1; fi + +RENTAL_ID="" +BLOCK_ID="" +BOOKING_ID="" +REVIEW_ID="" +UPGRADED_SUB="" + +cleanup() { + [[ -n "$RENTAL_ID" ]] && api_call DELETE "/api/rentals/$RENTAL_ID" "$DAVID_TOKEN" >/dev/null 2>&1 + [[ -n "$UPGRADED_SUB" ]] && api_call POST "/api/subscriptions/cancel" "$DAVID_TOKEN" >/dev/null 2>&1 +} +trap cleanup EXIT + +# Upgrade David to PRO (he has 4 active rentals, BASIC limit is 3) +api_call POST "/api/subscriptions/create" "$DAVID_TOKEN" '{"tier":"PRO"}' >/dev/null 2>&1 +UPGRADED_SUB="yes" + +# 7.1 Create rental (David is verified landlord) +r=$(api_call POST "/api/rentals" "$DAVID_TOKEN" '{"title":"QA Test Rental Apartment","description":"A test rental listing for automated QA testing suite","category":"APARTMENT","location":"Chicago, IL","dailyPrice":100,"depositAmount":200,"minDays":1,"maxDays":30}') +assert_status "7.1 Create rental" "201" "$(get_status "$r")" +b=$(get_body "$r") +RENTAL_ID=$(echo "$b" | jq -r '.id // empty') +assert_json "7.1 Status DRAFT" "$b" ".status" "DRAFT" + +# 7.2 Activate → PENDING_REVIEW (rentalAutoApprove=false) +r=$(api_call POST "/api/rentals/$RENTAL_ID/activate" "$DAVID_TOKEN") +assert_status "7.2 Activate rental" "200" "$(get_status "$r")" +b=$(get_body "$r") +status=$(echo "$b" | jq -r '.status') +if [[ "$status" == "PENDING_REVIEW" || "$status" == "ACTIVE" ]]; then + printf " ${GREEN}PASS${NC} 7.2 Status is %s\n" "$status"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 7.2 Unexpected status: %s\n" "$status"; ((FAIL_COUNT++)) +fi + +# 7.3 Admin approves (Carol is MODERATOR) +if [[ "$status" == "PENDING_REVIEW" ]]; then + r=$(api_call PATCH "/api/admin/rentals/$RENTAL_ID/approve" "$CAROL_TOKEN") + assert_status "7.3 Admin approve" "200" "$(get_status "$r")" +else + skip_test "7.3 Admin approve" "Already ACTIVE" +fi + +# 7.4 Verify active +r=$(api_call GET "/api/rentals/$RENTAL_ID") +assert_status "7.4 GET rental" "200" "$(get_status "$r")" +assert_json "7.4 Status ACTIVE" "$(get_body "$r")" ".status" "ACTIVE" + +# 7.5 Add availability block +BLOCK_START="2026-12-25T00:00:00.000Z" +BLOCK_END="2026-12-31T00:00:00.000Z" +r=$(api_call POST "/api/rentals/$RENTAL_ID/availability" "$DAVID_TOKEN" "{\"startDate\":\"$BLOCK_START\",\"endDate\":\"$BLOCK_END\",\"reason\":\"Holiday block\"}") +assert_status "7.5 Add availability block" "201" "$(get_status "$r")" +BLOCK_ID=$(get_body "$r" | jq -r '.id // empty') + +# 7.6 Check availability +r=$(api_call GET "/api/rentals/$RENTAL_ID/availability") +assert_status "7.6 GET availability" "200" "$(get_status "$r")" + +# 7.7 Remove block +if [[ -n "$BLOCK_ID" ]]; then + r=$(api_call DELETE "/api/rentals/$RENTAL_ID/availability/$BLOCK_ID" "$DAVID_TOKEN") + assert_status "7.7 Remove block" "200" "$(get_status "$r")" +fi + +# 7.8 Create booking (Alice books David's rental) +START=$(date -v+60d -u +"%Y-%m-%dT00:00:00.000Z" 2>/dev/null || date -d "+60 days" -u +"%Y-%m-%dT00:00:00.000Z") +END=$(date -v+63d -u +"%Y-%m-%dT00:00:00.000Z" 2>/dev/null || date -d "+63 days" -u +"%Y-%m-%dT00:00:00.000Z") +r=$(api_call POST "/api/bookings" "$ALICE_TOKEN" "{\"rentalListingId\":\"$RENTAL_ID\",\"periodType\":\"DAILY\",\"startDate\":\"$START\",\"endDate\":\"$END\",\"message\":\"QA test booking\"}") +s=$(get_status "$r") +b=$(get_body "$r") +if [[ "$s" == "201" ]]; then + BOOKING_ID=$(echo "$b" | jq -r '.id // empty') + assert_status "7.8 Create booking" "201" "$s" + assert_json "7.8 Status PENDING" "$b" ".status" "PENDING" +else + printf " ${RED}FAIL${NC} 7.8 Create booking (HTTP %s: %s)\n" "$s" "$(echo "$b" | jq -r '.message // empty')" + ((FAIL_COUNT++)) +fi + +# 7.9 Confirm booking (David) +if [[ -n "$BOOKING_ID" ]]; then + r=$(api_call PATCH "/api/bookings/$BOOKING_ID/confirm" "$DAVID_TOKEN") + assert_status "7.9 Confirm booking" "200" "$(get_status "$r")" + assert_json "7.9 Status CONFIRMED" "$(get_body "$r")" ".status" "CONFIRMED" +fi + +# 7.10 Complete booking (David) +if [[ -n "$BOOKING_ID" ]]; then + r=$(api_call PATCH "/api/bookings/$BOOKING_ID/complete" "$DAVID_TOKEN") + assert_status "7.10 Complete booking" "200" "$(get_status "$r")" + assert_json "7.10 Status COMPLETED" "$(get_body "$r")" ".status" "COMPLETED" +fi + +# 7.11 Create review (Alice reviews) +if [[ -n "$BOOKING_ID" ]]; then + r=$(api_call POST "/api/rental-reviews" "$ALICE_TOKEN" "{\"bookingId\":\"$BOOKING_ID\",\"rating\":4,\"comment\":\"QA test review - good rental\"}") + s=$(get_status "$r") + if [[ "$s" == "201" ]]; then + REVIEW_ID=$(get_body "$r" | jq -r '.id // empty') + assert_status "7.11 Create review" "201" "$s" + else + printf " ${RED}FAIL${NC} 7.11 Create review (HTTP %s)\n" "$s"; ((FAIL_COUNT++)) + fi +fi + +# 7.12 Landlord responds to review +if [[ -n "$REVIEW_ID" ]]; then + r=$(api_call PATCH "/api/rental-reviews/$REVIEW_ID/respond" "$DAVID_TOKEN" '{"response":"Thanks for the QA review!"}') + assert_status "7.12 Review response" "200" "$(get_status "$r")" +fi + +# 7.13 Landlord bookings +r=$(api_call GET "/api/bookings" "$DAVID_TOKEN") +assert_status "7.13 Landlord bookings" "200" "$(get_status "$r")" + +# 7.14 Tenant bookings +r=$(api_call GET "/api/bookings" "$ALICE_TOKEN") +assert_status "7.14 Tenant bookings" "200" "$(get_status "$r")" + +# 7.15 Payouts +r=$(api_call GET "/api/payouts" "$DAVID_TOKEN") +assert_status "7.15 GET /payouts" "200" "$(get_status "$r")" + +# 7.16 Rental favorites toggle +r=$(api_call POST "/api/rentals/rental-01/favorite" "$ALICE_TOKEN") +assert_status "7.16 Toggle rental fav" "200" "$(get_status "$r")" + +# 7.17 Cannot book own rental +r=$(api_call POST "/api/bookings" "$DAVID_TOKEN" "{\"rentalListingId\":\"rental-01\",\"periodType\":\"DAILY\",\"startDate\":\"$START\",\"endDate\":\"$END\"}") +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "403" ]]; then + printf " ${GREEN}PASS${NC} 7.17 Cannot book own rental (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 7.17 Expected 400/403, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 7.18 Pause rental +r=$(api_call POST "/api/rentals/$RENTAL_ID/pause" "$DAVID_TOKEN") +assert_status "7.18 Pause rental" "200" "$(get_status "$r")" + +# 7.19 Delete test rental (cleanup) +r=$(api_call DELETE "/api/rentals/$RENTAL_ID" "$DAVID_TOKEN") +assert_status "7.19 Delete rental" "200" "$(get_status "$r")" +RENTAL_ID="" + +print_summary "Stage 7: Rental Workflow" diff --git a/tests/stage-08-subscriptions.sh b/tests/stage-08-subscriptions.sh new file mode 100755 index 0000000..35769f8 --- /dev/null +++ b/tests/stage-08-subscriptions.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 8: Subscriptions" + +EVA_TOKEN=$(login_as "eva.johnson@example.com") +if [[ -z "$EVA_TOKEN" ]]; then echo "FATAL: Cannot login"; exit 1; fi + +ORIGINAL_TIER="" +cleanup() { + # Restore to BASIC if changed + if [[ -n "$ORIGINAL_TIER" && "$ORIGINAL_TIER" != "null" ]]; then + api_call POST "/api/subscriptions/cancel" "$EVA_TOKEN" >/dev/null 2>&1 + fi +} +trap cleanup EXIT + +# 8.1 Get tiers (public) +r=$(api_call GET "/api/subscriptions/tiers") +assert_status "8.1 GET /tiers" "200" "$(get_status "$r")" +assert_json_array_min "8.1 Has tiers" "$(get_body "$r")" "." 3 + +# 8.2 Get current subscription +r=$(api_call GET "/api/subscriptions/current" "$EVA_TOKEN") +assert_status "8.2 GET /current" "200" "$(get_status "$r")" +b=$(get_body "$r") +assert_json_exists "8.2 Has tierConfig" "$b" ".tierConfig" +ORIGINAL_TIER=$(echo "$b" | jq -r '.subscription.tier // empty') + +# 8.3 Upgrade to PRO +r=$(api_call POST "/api/subscriptions/create" "$EVA_TOKEN" '{"tier":"PRO"}') +assert_status "8.3 Upgrade to PRO" "200" "$(get_status "$r")" + +# 8.4 Verify current is PRO +r=$(api_call GET "/api/subscriptions/current" "$EVA_TOKEN") +assert_json "8.4 Tier is PRO" "$(get_body "$r")" ".subscription.tier" "PRO" + +# 8.5 Duplicate tier +r=$(api_call POST "/api/subscriptions/create" "$EVA_TOKEN" '{"tier":"PRO"}') +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "409" ]]; then + printf " ${GREEN}PASS${NC} 8.5 Duplicate tier rejected (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 8.5 Expected 400/409, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 8.6 Cancel subscription +r=$(api_call POST "/api/subscriptions/cancel" "$EVA_TOKEN") +assert_status "8.6 Cancel subscription" "200" "$(get_status "$r")" +ORIGINAL_TIER="" # Restored + +# 8.7 Invalid tier +r=$(api_call POST "/api/subscriptions/create" "$EVA_TOKEN" '{"tier":"INVALID"}') +assert_status "8.7 Invalid tier" "400" "$(get_status "$r")" + +# 8.8 Verify back to BASIC after cancel +r=$(api_call GET "/api/subscriptions/current" "$EVA_TOKEN") +b=$(get_body "$r") +tier=$(echo "$b" | jq -r '.subscription.tier // .tierConfig.tier // empty') +if [[ "$tier" == "BASIC" || -z "$tier" ]]; then + printf " ${GREEN}PASS${NC} 8.8 Back to BASIC (tier=%s)\n" "${tier:-none}"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 8.8 Expected BASIC, got %s\n" "$tier"; ((FAIL_COUNT++)) +fi + +print_summary "Stage 8: Subscriptions" diff --git a/tests/stage-09-chat-notifications.sh b/tests/stage-09-chat-notifications.sh new file mode 100755 index 0000000..1db7f04 --- /dev/null +++ b/tests/stage-09-chat-notifications.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 9: Chat & Notifications" + +ALICE_TOKEN=$(login_as "alice.chen@example.com") +BOB_TOKEN=$(login_as "bob.martinez@example.com") +DAVID_TOKEN=$(login_as "david.kim@example.com") +if [[ -z "$ALICE_TOKEN" || -z "$BOB_TOKEN" ]]; then echo "FATAL: Cannot login"; exit 1; fi + +CONV_ID="" +cleanup() { + [[ -n "$CONV_ID" ]] && api_call DELETE "/api/chat/conversations/$CONV_ID" "$ALICE_TOKEN" >/dev/null 2>&1 +} +trap cleanup EXIT + +# 9.1 Create conversation (Alice → Bob) +r=$(api_call POST "/api/chat/conversations" "$ALICE_TOKEN" '{"recipientId":"user-bob","message":"QA test message from Alice"}') +assert_status "9.1 Create conversation" "200" "$(get_status "$r")" +b=$(get_body "$r") +CONV_ID=$(echo "$b" | jq -r '.id // .conversation.id // empty') + +# 9.2 Get conversations +r=$(api_call GET "/api/chat/conversations" "$ALICE_TOKEN") +assert_status "9.2 GET conversations" "200" "$(get_status "$r")" + +# 9.3 Get messages +if [[ -n "$CONV_ID" ]]; then + r=$(api_call GET "/api/chat/conversations/$CONV_ID/messages" "$ALICE_TOKEN") + assert_status "9.3 GET messages" "200" "$(get_status "$r")" +fi + +# 9.4 Bob sees conversation +r=$(api_call GET "/api/chat/conversations" "$BOB_TOKEN") +assert_status "9.4 Bob's conversations" "200" "$(get_status "$r")" + +# 9.5 Bob replies (reuses same conversation) +r=$(api_call POST "/api/chat/conversations" "$BOB_TOKEN" '{"recipientId":"user-alice","message":"QA reply from Bob"}') +assert_status "9.5 Bob replies" "200" "$(get_status "$r")" + +# 9.6 Cannot message self +r=$(api_call POST "/api/chat/conversations" "$ALICE_TOKEN" '{"recipientId":"user-alice","message":"self"}') +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "403" ]]; then + printf " ${GREEN}PASS${NC} 9.6 Cannot message self (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 9.6 Expected 400/403, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 9.7 Blocked user messaging (Carol blocked David) +if [[ -n "$DAVID_TOKEN" ]]; then + r=$(api_call POST "/api/chat/conversations" "$DAVID_TOKEN" '{"recipientId":"user-carol","message":"blocked test"}') + s=$(get_status "$r") + if [[ "$s" == "403" || "$s" == "400" ]]; then + printf " ${GREEN}PASS${NC} 9.7 Blocked user rejected (HTTP %s)\n" "$s"; ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} 9.7 Expected 403/400, got %s\n" "$s"; ((FAIL_COUNT++)) + fi +fi + +# 9.8 Get notifications +r=$(api_call GET "/api/notifications" "$ALICE_TOKEN") +assert_status "9.8 GET /notifications" "200" "$(get_status "$r")" + +# 9.9 Unread count +r=$(api_call GET "/api/notifications/unread-count" "$ALICE_TOKEN") +assert_status "9.9 Unread count" "200" "$(get_status "$r")" + +# 9.10 Mark all read +r=$(api_call PATCH "/api/notifications/read-all" "$ALICE_TOKEN") +assert_status "9.10 Mark all read" "200" "$(get_status "$r")" + +# 9.11 Delete test conversation +if [[ -n "$CONV_ID" ]]; then + r=$(api_call DELETE "/api/chat/conversations/$CONV_ID" "$ALICE_TOKEN") + assert_status "9.11 Delete conversation" "200" "$(get_status "$r")" + CONV_ID="" +fi + +print_summary "Stage 9: Chat & Notifications" diff --git a/tests/stage-10-admin.sh b/tests/stage-10-admin.sh new file mode 100755 index 0000000..f328591 --- /dev/null +++ b/tests/stage-10-admin.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 10: Admin Operations" + +ALICE_TOKEN=$(login_as "alice.chen@example.com") # SUPER_ADMIN +BOB_TOKEN=$(login_as "bob.martinez@example.com") # ADMIN +CAROL_TOKEN=$(login_as "carol.nguyen@example.com") # MODERATOR +DAVID_TOKEN=$(login_as "david.kim@example.com") # USER +if [[ -z "$ALICE_TOKEN" || -z "$BOB_TOKEN" || -z "$CAROL_TOKEN" || -z "$DAVID_TOKEN" ]]; then + echo "FATAL: Cannot login all users"; exit 1 +fi + +cleanup() { + # Restore David's role to USER if changed + api_call PATCH "/api/admin/users/user-david/role" "$ALICE_TOKEN" '{"role":"USER"}' >/dev/null 2>&1 + # Unban David if banned + api_call POST "/api/admin/users/user-david/unban" "$BOB_TOKEN" >/dev/null 2>&1 +} +trap cleanup EXIT + +# --- Stats --- +# 10.1 Moderator can see stats +r=$(api_call GET "/api/admin/stats" "$CAROL_TOKEN") +assert_status "10.1 Stats (moderator)" "200" "$(get_status "$r")" +assert_json_exists "10.1 totalUsers" "$(get_body "$r")" ".totalUsers" + +# 10.2 Stats/listings (moderator) +r=$(api_call GET "/api/admin/stats/listings" "$CAROL_TOKEN") +assert_status "10.2 Stats/listings" "200" "$(get_status "$r")" + +# 10.3 Stats/revenue (moderator → denied, need admin) +r=$(api_call GET "/api/admin/stats/revenue" "$CAROL_TOKEN") +assert_status "10.3 Revenue denied for mod" "403" "$(get_status "$r")" + +# 10.4 Stats/revenue (admin) +r=$(api_call GET "/api/admin/stats/revenue" "$BOB_TOKEN") +assert_status "10.4 Revenue (admin)" "200" "$(get_status "$r")" + +# 10.5 Stats/users (admin) +r=$(api_call GET "/api/admin/stats/users" "$BOB_TOKEN") +assert_status "10.5 Stats/users" "200" "$(get_status "$r")" + +# --- Users --- +# 10.6 List users +r=$(api_call GET "/api/admin/users" "$CAROL_TOKEN") +assert_status "10.6 List users" "200" "$(get_status "$r")" + +# 10.7 Search users +r=$(api_call GET "/api/admin/users?search=alice" "$CAROL_TOKEN") +assert_status "10.7 Search users" "200" "$(get_status "$r")" + +# 10.8 User detail +r=$(api_call GET "/api/admin/users/user-david" "$CAROL_TOKEN") +assert_status "10.8 User detail" "200" "$(get_status "$r")" + +# 10.9 Change role (SUPER_ADMIN only) +r=$(api_call PATCH "/api/admin/users/user-david/role" "$ALICE_TOKEN" '{"role":"MODERATOR"}') +assert_status "10.9 Change role" "200" "$(get_status "$r")" +# Restore +api_call PATCH "/api/admin/users/user-david/role" "$ALICE_TOKEN" '{"role":"USER"}' >/dev/null + +# 10.10 Cannot change own role +r=$(api_call PATCH "/api/admin/users/user-alice/role" "$ALICE_TOKEN" '{"role":"USER"}') +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "403" ]]; then + printf " ${GREEN}PASS${NC} 10.10 Cannot change own role (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 10.10 Expected 400/403, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 10.11 Ban user (admin) +r=$(api_call POST "/api/admin/users/user-david/ban" "$BOB_TOKEN" '{"reason":"QA test ban"}') +assert_status "10.11 Ban David" "200" "$(get_status "$r")" + +# 10.12 Unban +r=$(api_call POST "/api/admin/users/user-david/unban" "$BOB_TOKEN") +assert_status "10.12 Unban David" "200" "$(get_status "$r")" + +# 10.13 Cannot ban SUPER_ADMIN +r=$(api_call POST "/api/admin/users/user-alice/ban" "$BOB_TOKEN" '{"reason":"test"}') +s=$(get_status "$r") +if [[ "$s" == "403" || "$s" == "400" ]]; then + printf " ${GREEN}PASS${NC} 10.13 Cannot ban super admin (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 10.13 Expected 403/400, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# --- Listings --- +# 10.14 Admin listings +r=$(api_call GET "/api/admin/listings" "$CAROL_TOKEN") +assert_status "10.14 Admin listings" "200" "$(get_status "$r")" + +# 10.15 Moderation queue +r=$(api_call GET "/api/admin/moderation/queue" "$CAROL_TOKEN") +assert_status "10.15 Mod queue" "200" "$(get_status "$r")" + +# --- Reports --- +# 10.16 Reports list +r=$(api_call GET "/api/admin/reports" "$CAROL_TOKEN") +assert_status "10.16 Reports list" "200" "$(get_status "$r")" + +# --- Settings --- +# 10.17 Get settings (admin) +r=$(api_call GET "/api/admin/settings" "$BOB_TOKEN") +assert_status "10.17 GET settings" "200" "$(get_status "$r")" + +# 10.18 Update settings (super_admin) +r=$(api_call PATCH "/api/admin/settings" "$ALICE_TOKEN" '{"listingFee":5}') +assert_status "10.18 Update settings" "200" "$(get_status "$r")" + +# --- Rentals --- +# 10.19 Admin rentals +r=$(api_call GET "/api/admin/rentals" "$CAROL_TOKEN") +assert_status "10.19 Admin rentals" "200" "$(get_status "$r")" + +# 10.20 Rental stats +r=$(api_call GET "/api/admin/rentals/stats" "$CAROL_TOKEN") +assert_status "10.20 Rental stats" "200" "$(get_status "$r")" + +# --- Role guard --- +# 10.21 Regular user cannot access admin +r=$(api_call GET "/api/admin/stats" "$DAVID_TOKEN") +assert_status "10.21 User denied admin" "403" "$(get_status "$r")" + +# 10.22 Admin bookings +r=$(api_call GET "/api/admin/bookings" "$CAROL_TOKEN") +assert_status "10.22 Admin bookings" "200" "$(get_status "$r")" + +# 10.23 Admin payments (admin only) +r=$(api_call GET "/api/admin/payments" "$BOB_TOKEN") +assert_status "10.23 Admin payments" "200" "$(get_status "$r")" + +# 10.24 Moderation logs (admin only) +r=$(api_call GET "/api/admin/moderation/logs" "$BOB_TOKEN") +assert_status "10.24 Mod logs" "200" "$(get_status "$r")" + +print_summary "Stage 10: Admin Operations" diff --git a/tests/stage-11-edge-cases.sh b/tests/stage-11-edge-cases.sh new file mode 100755 index 0000000..03f5072 --- /dev/null +++ b/tests/stage-11-edge-cases.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 11: Edge Cases & Validation" + +ALICE_TOKEN=$(login_as "alice.chen@example.com") +EVA_TOKEN=$(login_as "eva.johnson@example.com") +BOB_TOKEN=$(login_as "bob.martinez@example.com") +if [[ -z "$ALICE_TOKEN" || -z "$EVA_TOKEN" ]]; then echo "FATAL: Cannot login"; exit 1; fi + +# Register a temp user for ban test +TEMP_EMAIL="qa-ban-$(date +%s)@example.com" +r=$(api_call POST "/api/auth/register" "" "{\"fullName\":\"Ban Test\",\"email\":\"$TEMP_EMAIL\",\"password\":\"TestPass123\"}") +TEMP_TOKEN=$(get_body "$r" | jq -r '.accessToken // empty') +TEMP_USER_ID=$(get_body "$r" | jq -r '.user.id // empty') + +cleanup() { + # Unban temp user + [[ -n "$TEMP_USER_ID" ]] && api_call POST "/api/admin/users/$TEMP_USER_ID/unban" "$BOB_TOKEN" >/dev/null 2>&1 +} +trap cleanup EXIT + +# 11.1 Expired/garbage token +r=$(api_call GET "/api/auth/me" "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0IiwiaWF0IjoxNjAwMDAwMDAwLCJleHAiOjE2MDAwMDAwMDF9.invalid") +assert_status "11.1 Expired token" "401" "$(get_status "$r")" + +# 11.2 Malformed JSON body +r=$(curl -s -w '\n%{http_code}' -X POST "${BASE_URL}/api/auth/login" -H "Content-Type: application/json" -d "not valid json" 2>/dev/null) +s=$(get_status "$r") +if [[ "$s" == "400" || "$s" == "500" ]]; then + printf " ${GREEN}PASS${NC} 11.2 Malformed JSON rejected (HTTP %s)\n" "$s"; ((PASS_COUNT++)) +else + printf " ${RED}FAIL${NC} 11.2 Expected 400/500, got %s\n" "$s"; ((FAIL_COUNT++)) +fi + +# 11.3 Missing required fields +r=$(api_call POST "/api/listings" "$ALICE_TOKEN" '{}') +assert_status "11.3 Empty listing body" "400" "$(get_status "$r")" + +# 11.4 Invalid category +r=$(api_call POST "/api/listings" "$ALICE_TOKEN" '{"title":"Bad","description":"At least 10 chars here","price":10,"category":"INVALID","condition":"NEW","location":"X"}') +assert_status "11.4 Invalid category" "400" "$(get_status "$r")" + +# 11.5 Invalid condition +r=$(api_call POST "/api/listings" "$ALICE_TOKEN" '{"title":"Bad","description":"At least 10 chars here","price":10,"category":"OTHER","condition":"BROKEN","location":"X"}') +assert_status "11.5 Invalid condition" "400" "$(get_status "$r")" + +# 11.6 Negative price +r=$(api_call POST "/api/listings" "$ALICE_TOKEN" '{"title":"Bad","description":"At least 10 chars here","price":-10,"category":"OTHER","condition":"NEW","location":"X"}') +assert_status "11.6 Negative price" "400" "$(get_status "$r")" + +# 11.7 Offer on non-existent listing +r=$(api_call POST "/api/offers" "$EVA_TOKEN" '{"amount":100,"listingId":"fake-listing-xyz"}') +assert_status "11.7 Offer on fake listing" "404" "$(get_status "$r")" + +# 11.8 Edit another user's listing +r=$(api_call PUT "/api/listings/listing-01" "$EVA_TOKEN" '{"title":"Hacked"}') +assert_status "11.8 Edit other listing" "403" "$(get_status "$r")" + +# 11.9 Delete another user's listing +r=$(api_call DELETE "/api/listings/listing-01" "$EVA_TOKEN") +assert_status "11.9 Delete other listing" "403" "$(get_status "$r")" + +# 11.10 Unauthenticated protected endpoints +for path in "/api/listings" "/api/offers" "/api/bookings" "/api/reports"; do + r=$(api_call POST "$path" "" '{}') + s=$(get_status "$r") + if [[ "$s" == "401" ]]; then + printf " ${GREEN}PASS${NC} 11.10 POST %s → 401\n" "$path"; ((PASS_COUNT++)) + else + printf " ${RED}FAIL${NC} 11.10 POST %s expected 401, got %s\n" "$path" "$s"; ((FAIL_COUNT++)) + fi +done + +# 11.11 Ban user then test login +if [[ -n "$TEMP_USER_ID" ]]; then + api_call POST "/api/admin/users/$TEMP_USER_ID/ban" "$BOB_TOKEN" '{"reason":"QA ban test"}' >/dev/null + r=$(api_call POST "/api/auth/login" "" "{\"email\":\"$TEMP_EMAIL\",\"password\":\"TestPass123\"}") + assert_status "11.11 Banned user login" "403" "$(get_status "$r")" + # Unban + api_call POST "/api/admin/users/$TEMP_USER_ID/unban" "$BOB_TOKEN" >/dev/null +fi + +# 11.12 Report creation +r=$(api_call POST "/api/reports" "$EVA_TOKEN" '{"targetType":"LISTING","targetId":"listing-01","reason":"SPAM","description":"QA test report"}') +assert_status "11.12 Create report" "201" "$(get_status "$r")" + +# 11.13 Report invalid target +r=$(api_call POST "/api/reports" "$EVA_TOKEN" '{"targetType":"LISTING","targetId":"fake-id","reason":"SPAM"}') +assert_status "11.13 Report fake target" "404" "$(get_status "$r")" + +# 11.14 Booking with past dates +START="2020-01-01T00:00:00.000Z" +END="2020-01-03T00:00:00.000Z" +r=$(api_call POST "/api/bookings" "$ALICE_TOKEN" "{\"rentalListingId\":\"rental-01\",\"periodType\":\"DAILY\",\"startDate\":\"$START\",\"endDate\":\"$END\"}") +assert_status "11.14 Past date booking" "400" "$(get_status "$r")" + +# 11.15 Booking with end before start +r=$(api_call POST "/api/bookings" "$ALICE_TOKEN" '{"rentalListingId":"rental-01","periodType":"DAILY","startDate":"2027-06-05T00:00:00.000Z","endDate":"2027-06-01T00:00:00.000Z"}') +assert_status "11.15 End before start" "400" "$(get_status "$r")" + +print_summary "Stage 11: Edge Cases" diff --git a/tests/stage-12-client-pages.sh b/tests/stage-12-client-pages.sh new file mode 100755 index 0000000..f4c024f --- /dev/null +++ b/tests/stage-12-client-pages.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +stage_header "Stage 12: Client Pages" + +# Check if client is running +r=$(curl -s -o /dev/null -w "%{http_code}" "$CLIENT_URL" 2>/dev/null) +if [[ "$r" != "200" ]]; then + echo " Client not running at $CLIENT_URL — skipping all tests" + exit 0 +fi + +check_page() { + local name="$1" path="$2" expected="${3:-200}" + local status + status=$(curl -s -o /dev/null -w "%{http_code}" "${CLIENT_URL}${path}" 2>/dev/null) + assert_status "$name" "$expected" "$status" +} + +# Public pages +check_page "12.1 Homepage" "/" +check_page "12.2 Login" "/login" +check_page "12.3 Signup" "/signup" +check_page "12.4 About" "/about" +check_page "12.5 Privacy" "/privacy" +check_page "12.6 Terms" "/terms" +check_page "12.7 Help" "/help" +check_page "12.8 Contact" "/contact" +check_page "12.9 Rentals" "/rentals" +check_page "12.10 Listing detail" "/listings/listing-01" +check_page "12.11 Rental detail" "/rentals/rental-01" + +# SPA routes (all return 200 — HTML shell, auth handled client-side) +check_page "12.12 Dashboard" "/dashboard" +check_page "12.13 Admin" "/admin" +check_page "12.14 Landlord" "/landlord" +check_page "12.15 Sell" "/sell" +check_page "12.16 Settings" "/dashboard/settings" + +# API proxy through client +r=$(curl -s -o /dev/null -w "%{http_code}" "${CLIENT_URL}/api/listings" 2>/dev/null) +assert_status "12.17 API proxy" "200" "$r" + +print_summary "Stage 12: Client Pages"