#!/usr/bin/env bash set -uo pipefail BASE="https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/api/v1" PASS=0 FAIL=0 CLEANUP_PRODUCT_IDS=() CLEANUP_SALE_IDS=() CLEANUP_COUPON_IDS=() check() { local label="$1" expect="$2" method="$3" path="$4"; shift 4 local code=$(curl -s -o /tmp/qa_body.json -w "%{http_code}" -X "$method" "$BASE$path" "$@" 2>/dev/null) if [ "$code" = "$expect" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: $label — expected $expect got $code"; fi } check_field() { local actual=$(jq -r "$2" /tmp/qa_body.json 2>/dev/null) if [ "$actual" = "$3" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: $1 — expected '$3' got '$actual'"; fi } check_not_500() { local label="$1" method="$2" path="$3"; shift 3 local code=$(curl -s -o /tmp/qa_body.json -w "%{http_code}" -X "$method" "$BASE$path" "$@" 2>/dev/null) if [ "$code" != "500" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: $label — got 500 Internal Server Error"; fi } check_true() { local label="$1" expr="$2" local result=$(jq "$expr" /tmp/qa_body.json 2>/dev/null) if [ "$result" = "true" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: $label"; fi } echo "=== Logging in ===" ADMIN_TOKEN=$(curl -s -X POST "$BASE/auth/login" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' | jq -r '.token') STAFF_TOKEN=$(curl -s -X POST "$BASE/auth/login" -H 'Content-Type: application/json' -d '{"username":"staff","password":"staff123"}' | jq -r '.token') CUST_TOKEN=$(curl -s -X POST "$BASE/auth/login" -H 'Content-Type: application/json' -d '{"username":"customer","password":"customer123"}' | jq -r '.token') if [ "$ADMIN_TOKEN" = "null" ] || [ -z "$ADMIN_TOKEN" ]; then echo "FATAL: Admin login failed"; exit 1; fi if [ "$STAFF_TOKEN" = "null" ] || [ -z "$STAFF_TOKEN" ]; then echo "FATAL: Staff login failed"; exit 1; fi if [ "$CUST_TOKEN" = "null" ] || [ -z "$CUST_TOKEN" ]; then echo "FATAL: Customer login failed"; exit 1; fi echo "All 3 logins OK" A=(-H "Authorization: Bearer $ADMIN_TOKEN") S=(-H "Authorization: Bearer $STAFF_TOKEN") C=(-H "Authorization: Bearer $CUST_TOKEN") J=(-H "Content-Type: application/json") cleanup() { echo "" echo "=== Cleanup ===" for pid in "${CLEANUP_PRODUCT_IDS[@]}"; do curl -s -o /dev/null -X DELETE "$BASE/products/$pid" "${A[@]}" 2>/dev/null done for sid in "${CLEANUP_SALE_IDS[@]}"; do curl -s -o /dev/null -X DELETE "$BASE/sales/$sid" "${A[@]}" 2>/dev/null done for cid in "${CLEANUP_COUPON_IDS[@]}"; do curl -s -o /dev/null -X DELETE "$BASE/coupons/$cid" "${A[@]}" 2>/dev/null done echo "Cleanup done" } trap cleanup EXIT ############################################################################### echo "" echo "========== C — CONFORMANCE ==========" ############################################################################### echo "--- Response format validation ---" check "GET /products 200" 200 GET "/products" check_true "products .content is array" '.content | type == "array"' check_true "products has totalElements" '.totalElements != null' check_true "products has totalPages" '.totalPages != null' check_true "products has number" '.number != null' check_true "products has size" '.size != null' check "GET /products/1 200" 200 GET "/products/1" check_true "prodPrice is number ≤2 decimals" '(.prodPrice | tostring | test("^[0-9]+(\\.[0-9]{1,2})?$"))' check "GET /sales admin 200" 200 GET "/sales" "${A[@]}" "${J[@]}" check_true "saleDate has ISO T" '(.content[0].saleDate | test("T"))' check "GET /appointments admin 200" 200 GET "/appointments" "${A[@]}" "${J[@]}" check_true "appointmentDate YYYY-MM-DD" '(.content[0].appointmentDate | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}"))' local_code=$(curl -s -o /tmp/qa_body.json -w "%{http_code}" -D /tmp/qa_headers.txt -X GET "$BASE/auth/me" "${A[@]}" 2>/dev/null) if grep -qi "application/json" /tmp/qa_headers.txt 2>/dev/null; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: /auth/me Content-Type not json"; fi local_code=$(curl -s -o /tmp/qa_body.json -w "%{http_code}" -D /tmp/qa_headers.txt -X GET "$BASE/users/1/avatar/file" "${A[@]}" 2>/dev/null) if grep -qi "image/" /tmp/qa_headers.txt 2>/dev/null; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: avatar Content-Type not image/"; fi echo "--- Status enum validation ---" check "GET /appointments enum" 200 GET "/appointments?size=50" "${A[@]}" check_true "appointmentStatus valid" '[.content[].appointmentStatus] | all(. == "Scheduled" or . == "Completed" or . == "Cancelled" or . == "In Progress" or . == "No Show")' check "GET /pets enum" 200 GET "/pets?size=100" check_true "petStatus valid" '[.content[].petStatus] | all(. == "Available" or . == "Pending" or . == "Adopted" or . == "Owned")' check "GET /sales enum" 200 GET "/sales?size=50" "${A[@]}" check_true "channel valid" '[.content[].channel] | all(. == "IN_STORE" or . == "ONLINE")' check "GET /users enum" 200 GET "/users?size=100" "${A[@]}" check_true "role valid" '[.content[].role] | all(. == "ADMIN" or . == "STAFF" or . == "CUSTOMER")' check "GET /coupons enum" 200 GET "/coupons" "${A[@]}" check_true "discountType valid" '[.[].discountType] | all(. == "PERCENTAGE" or . == "FIXED")' echo "--- Error format validation ---" check "GET /products/999999 404" 404 GET "/products/999999" check_true "404 has status" '.status != null' check_true "404 has message" '.message != null' check_true "404 has path" '.path != null' check_true "404 has timestamp" '.timestamp != null' echo "--- Input format enforcement ---" check_not_500 "invalid JSON body" POST "/products" "${A[@]}" "${J[@]}" -d '{bad json}' check_not_500 "text/plain content-type" POST "/products" "${A[@]}" -H "Content-Type: text/plain" -d 'hello' check_not_500 "empty body" POST "/products" "${A[@]}" "${J[@]}" -d '' check_not_500 "XML body" POST "/products" "${A[@]}" -H "Content-Type: application/xml" -d '' echo "--- Security conformance ---" check_not_500 "SQL injection in search" GET "/products?q=%27%20OR%201%3D1--" check_not_500 "XSS in search" GET '/products?q=' check_not_500 "XSS in prodName" POST "/products" "${A[@]}" "${J[@]}" -d '{"prodName":"","prodPrice":9.99,"categoryId":1}' XSS_ID=$(jq -r '.prodId // empty' /tmp/qa_body.json 2>/dev/null) if [ -n "$XSS_ID" ]; then CLEANUP_PRODUCT_IDS+=("$XSS_ID") check "GET XSS product" 200 GET "/products/$XSS_ID" check_true "XSS stored literally" '(.prodName | contains("/dev/null) if [ -n "$UNI_ID" ]; then CLEANUP_PRODUCT_IDS+=("$UNI_ID") check "GET unicode product" 200 GET "/products/$UNI_ID" check_field "unicode preserved" '.prodName' 'Café Résumé' fi check_not_500 "null byte in category" POST "/categories" "${A[@]}" "${J[@]}" -d '{"categoryName":"test\u0000inject"}' ############################################################################### echo "" echo "========== O — ORDERING ==========" ############################################################################### check "products price asc" 200 GET "/products?sort=prodPrice,asc&size=100" check_true "prices non-decreasing" '[.content[].prodPrice] | . as $a | [range(1;length)] | all(. as $i | $a[$i] >= $a[$i-1])' check "products price desc" 200 GET "/products?sort=prodPrice,desc&size=100" check_true "prices non-increasing" '[.content[].prodPrice] | . as $a | [range(1;length)] | all(. as $i | $a[$i] <= $a[$i-1])' check "products name asc" 200 GET "/products?sort=prodName,asc&size=100" check_true "names alphabetical" '[.content[].prodName] | . as $a | [range(1;length)] | all(. as $i | $a[$i] >= $a[$i-1])' check "sales date desc" 200 GET "/sales?sort=saleDate,desc&size=50" "${A[@]}" check_true "sale dates non-increasing" '[.content[].saleDate] | . as $a | [range(1;length)] | all(. as $i | $a[$i] <= $a[$i-1])' check "users lastName asc" 200 GET "/users?sort=lastName,asc&size=100" "${A[@]}" check_true "lastNames alphabetical" '[.content[].lastName] | . as $a | [range(1;length)] | all(. as $i | $a[$i] >= $a[$i-1])' check_not_500 "sort by nonexistent field" GET "/products?sort=nonexistent,asc" check "page 0 size 5" 200 GET "/products?page=0&size=5" PAGE0_IDS=$(jq -r '[.content[].prodId] | join(",")' /tmp/qa_body.json) check "page 1 size 5" 200 GET "/products?page=1&size=5" PAGE1_IDS=$(jq -r '[.content[].prodId] | join(",")' /tmp/qa_body.json) OVERLAP=0 IFS=',' read -ra P0 <<< "$PAGE0_IDS" IFS=',' read -ra P1 <<< "$PAGE1_IDS" for a in "${P0[@]}"; do for b in "${P1[@]}"; do [ "$a" = "$b" ] && OVERLAP=1; done; done if [ "$OVERLAP" = "0" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: page 0 and page 1 overlap"; fi check "conversations" 200 GET "/chat/conversations" "${C[@]}" CONV_ID=$(jq -r '.[0].conversationId // empty' /tmp/qa_body.json 2>/dev/null) if [ -n "$CONV_ID" ]; then check "conv messages" 200 GET "/chat/conversations/$CONV_ID/messages" "${C[@]}" check_true "message timestamps ascending" '. as $a | [range(1;length)] | all(. as $i | $a[$i].timestamp >= $a[$i-1].timestamp)' fi check "activity logs desc" 200 GET "/activity-logs?limit=10" "${A[@]}" check_true "log timestamps descending" '. as $a | [range(1;length)] | all(. as $i | $a[$i].logTimestamp <= $a[$i-1].logTimestamp)' check "availability times" 200 GET "/appointments/availability?storeId=1&serviceId=1&date=2027-06-01" "${A[@]}" check_true "times ascending" '. as $a | [range(1;length)] | all(. as $i | $a[$i] >= $a[$i-1])' ############################################################################### echo "" echo "========== R — RANGE ==========" ############################################################################### echo "--- Data range checks ---" check "products range" 200 GET "/products?size=200" check_true "all prodPrice > 0" '[.content[].prodPrice] | all(. > 0)' check "services range" 200 GET "/services" check_true "all servicePrice > 0" '[.[].servicePrice] | all(. > 0)' check_true "all serviceDuration > 0" '[.[].serviceDuration] | all(. > 0)' check "inventory range" 200 GET "/inventory" "${A[@]}" check_true "all quantity >= 0" '[.content[].quantity] | all(. >= 0)' check "users loyalty" 200 GET "/users?size=100" "${A[@]}" check_true "all loyaltyPoints >= 0" '[.content[].loyaltyPoints] | all(. >= 0)' check "coupons range" 200 GET "/coupons" "${A[@]}" check_true "PERCENTAGE < 100 and > 0" '[.[] | select(.discountType == "PERCENTAGE") | .discountValue] | all(. > 0 and . < 100)' check "pets age range" 200 GET "/pets?size=200" check_true "all petAge >= 0" '[.content[].petAge | select(. != null)] | all(. >= 0)' echo "--- Input range enforcement ---" check "negative price → 400" 400 POST "/products" "${A[@]}" "${J[@]}" -d '{"prodName":"QA_NEG","prodPrice":-1,"categoryId":1}' NEG_ID=$(jq -r '.prodId // empty' /tmp/qa_body.json 2>/dev/null) [ -n "$NEG_ID" ] && CLEANUP_PRODUCT_IDS+=("$NEG_ID") check "zero price → 400" 400 POST "/products" "${A[@]}" "${J[@]}" -d '{"prodName":"QA_ZERO","prodPrice":0,"categoryId":1}' Z_ID=$(jq -r '.prodId // empty' /tmp/qa_body.json 2>/dev/null) [ -n "$Z_ID" ] && CLEANUP_PRODUCT_IDS+=("$Z_ID") check "zero duration → 400" 400 POST "/services" "${A[@]}" "${J[@]}" -d '{"serviceName":"QA_DUR0","servicePrice":10,"serviceDuration":0}' check "neg duration → 400" 400 POST "/services" "${A[@]}" "${J[@]}" -d '{"serviceName":"QA_DURN","servicePrice":10,"serviceDuration":-5}' check "neg cart qty → 400" 400 POST "/cart/add" "${C[@]}" "${J[@]}" -d '{"prodId":1,"storeId":1,"quantity":-1}' check "zero coupon pct → 400" 400 POST "/coupons" "${A[@]}" "${J[@]}" -d '{"couponCode":"QA_ZERO_PCT","discountType":"PERCENTAGE","discountValue":0}' check_not_500 "huge price" POST "/products" "${A[@]}" "${J[@]}" -d '{"prodName":"QA_HUGE","prodPrice":99999999.99,"categoryId":1}' HUGE_ID=$(jq -r '.prodId // empty' /tmp/qa_body.json 2>/dev/null) [ -n "$HUGE_ID" ] && CLEANUP_PRODUCT_IDS+=("$HUGE_ID") echo "--- Extreme pagination ---" check_not_500 "page=-1" GET "/products?page=-1" check_not_500 "size=0" GET "/products?size=0" check_not_500 "size=10000" GET "/products?size=10000" echo "--- String length ---" LONG_USER=$(printf 'u%.0s' $(seq 1 60)) check "60-char username → 400" 400 POST "/auth/register" "${J[@]}" -d "{\"username\":\"$LONG_USER\",\"password\":\"pass1234\",\"firstName\":\"Q\",\"lastName\":\"A\",\"email\":\"${LONG_USER}@test.com\"}" LONG_PNAME=$(printf 'p%.0s' $(seq 1 200)) check "200-char prodName → 400" 400 POST "/products" "${A[@]}" "${J[@]}" -d "{\"prodName\":\"$LONG_PNAME\",\"prodPrice\":9.99,\"categoryId\":1}" ############################################################################### echo "" echo "========== R — REFERENCE ==========" ############################################################################### echo "--- FK integrity: sales ---" check "sales sample" 200 GET "/sales?size=3" "${A[@]}" for i in 0 1 2; do SID=$(jq -r ".content[$i].storeId // empty" /tmp/qa_body.json) EID=$(jq -r ".content[$i].employeeId // empty" /tmp/qa_body.json) [ -n "$SID" ] && check "sale[$i] store $SID exists" 200 GET "/stores/$SID" [ -n "$EID" ] && check "sale[$i] employee $EID exists" 200 GET "/users/$EID" "${A[@]}" done echo "--- FK integrity: appointments ---" check "appts sample" 200 GET "/appointments?size=3" "${A[@]}" cp /tmp/qa_body.json /tmp/qa_appts.json for i in 0 1 2; do SVID=$(jq -r ".content[$i].serviceId // empty" /tmp/qa_appts.json) STID=$(jq -r ".content[$i].storeId // empty" /tmp/qa_appts.json) CID=$(jq -r ".content[$i].customerId // empty" /tmp/qa_appts.json) EMID=$(jq -r ".content[$i].employeeId // empty" /tmp/qa_appts.json) [ -n "$SVID" ] && check "appt[$i] service $SVID" 200 GET "/services/$SVID" [ -n "$STID" ] && check "appt[$i] store $STID" 200 GET "/stores/$STID" [ -n "$CID" ] && check "appt[$i] customer $CID" 200 GET "/users/$CID" "${A[@]}" [ -n "$EMID" ] && check "appt[$i] employee $EMID" 200 GET "/users/$EMID" "${A[@]}" done echo "--- FK integrity: inventory ---" check "inv sample" 200 GET "/inventory?size=3" "${A[@]}" cp /tmp/qa_body.json /tmp/qa_inv.json for i in 0 1 2; do ISTID=$(jq -r ".content[$i].storeId // empty" /tmp/qa_inv.json) IPID=$(jq -r ".content[$i].prodId // empty" /tmp/qa_inv.json) [ -n "$ISTID" ] && check "inv[$i] store $ISTID" 200 GET "/stores/$ISTID" [ -n "$IPID" ] && check "inv[$i] product $IPID" 200 GET "/products/$IPID" done echo "--- FK integrity: products ---" check "products sample" 200 GET "/products?size=3" cp /tmp/qa_body.json /tmp/qa_prods.json for i in 0 1 2; do CATID=$(jq -r ".content[$i].categoryId // empty" /tmp/qa_prods.json) [ -n "$CATID" ] && check "prod[$i] category $CATID" 200 GET "/categories/$CATID" done echo "--- Cross-ref violations ---" check "sale bad storeId" 404 POST "/sales" "${S[@]}" "${J[@]}" -d '{"storeId":999,"paymentMethod":"Cash","channel":"IN_STORE","items":[{"prodId":1,"quantity":1}]}' check "appt bad serviceId" 404 POST "/appointments" "${A[@]}" "${J[@]}" -d '{"serviceId":999,"petId":53,"customerId":32,"storeId":2,"employeeId":7,"appointmentDate":"2027-09-01","appointmentTime":"10:00","appointmentStatus":"Scheduled"}' check "appt bad customerId" 404 POST "/appointments" "${A[@]}" "${J[@]}" -d '{"serviceId":1,"petId":53,"customerId":999,"storeId":2,"employeeId":7,"appointmentDate":"2027-09-01","appointmentTime":"10:00","appointmentStatus":"Scheduled"}' check "appt bad employeeId" 404 POST "/appointments" "${A[@]}" "${J[@]}" -d '{"serviceId":1,"petId":53,"customerId":32,"storeId":2,"employeeId":999,"appointmentDate":"2027-09-01","appointmentTime":"10:00","appointmentStatus":"Scheduled"}' echo "--- Dangling reference (FK on delete) ---" DEL_CAT_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$BASE/categories/1" "${A[@]}" 2>/dev/null) if [ "$DEL_CAT_CODE" != "204" ] && [ "$DEL_CAT_CODE" != "200" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: DELETE /categories/1 should fail (FK) — got $DEL_CAT_CODE"; fi DEL_STORE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$BASE/stores/1" "${A[@]}" 2>/dev/null) if [ "$DEL_STORE_CODE" != "204" ] && [ "$DEL_STORE_CODE" != "200" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: DELETE /stores/1 should fail (FK) — got $DEL_STORE_CODE"; fi echo "--- Avatar file references ---" check "admin avatar file" 200 GET "/users/1/avatar/file" "${A[@]}" check "user 16 avatar file" 200 GET "/users/16/avatar/file" "${A[@]}" check "product 1 image" 200 GET "/products/1/image" ############################################################################### echo "" echo "========== E — EXISTENCE ==========" ############################################################################### echo "--- Required fields never null ---" check "products fields" 200 GET "/products?size=100" check_true "products non-null fields" '.content | all(.prodId != null and .prodName != null and .prodPrice != null and .categoryId != null)' check "users fields" 200 GET "/users?size=100" "${A[@]}" check_true "users non-null fields" '.content | all(.id != null and .firstName != null and .lastName != null and .role != null)' check "sales fields" 200 GET "/sales?size=50" "${A[@]}" check_true "sales non-null fields" '.content | all(.saleId != null and .saleDate != null and .totalAmount != null and .storeId != null and .employeeId != null)' check "appts fields" 200 GET "/appointments?size=50" "${A[@]}" check_true "appts non-null fields" '.content | all(.appointmentId != null and .serviceId != null and .customerId != null and .storeId != null and .appointmentDate != null and .appointmentTime != null and .appointmentStatus != null)' check "coupons fields" 200 GET "/coupons" "${A[@]}" check_true "coupons non-null fields" '[.[] | .couponId != null and .couponCode != null and .discountType != null and .discountValue != null] | all' echo "--- Avatar existence for all roles ---" check "admin me avatar" 200 GET "/auth/me/avatar/file" "${A[@]}" check "staff me avatar" 200 GET "/auth/me/avatar/file" "${S[@]}" check "customer me avatar" 200 GET "/auth/me/avatar/file" "${C[@]}" echo "--- Empty search returns valid structure ---" check "empty product search" 200 GET "/products?q=zzzznothing" check_true "empty search .content is array" '.content | type == "array"' check_true "empty search totalElements 0" '.totalElements == 0' check "empty pet species" 200 GET "/pets?species=Unicorn" check_true "unicorn totalElements 0" '.totalElements == 0' check "future appts" 200 GET "/appointments?date=2099-12-31" "${A[@]}" check_true "far future totalElements 0" '.totalElements == 0' check "nonexistent activity log" 200 GET "/activity-logs?search=xyznonexistent" "${A[@]}" check_true "activity log empty array" 'length == 0' echo "--- Missing parameter handling ---" check_not_500 "cart no storeId" GET "/cart" "${C[@]}" check_not_500 "availability no params" GET "/appointments/availability" check_not_500 "checkout empty body" POST "/cart/checkout" "${C[@]}" "${J[@]}" -d '{}' ############################################################################### echo "" echo "========== C — CARDINALITY ==========" ############################################################################### echo "--- Unique constraints ---" check "dup username admin" 409 POST "/auth/register" "${J[@]}" -d '{"username":"admin","password":"pass1234","firstName":"Q","lastName":"A","email":"qadup1@test.com"}' DUP_CODE=$(cat /tmp/qa_body.json | jq -r '.status // empty' 2>/dev/null) if [ "$?" != "0" ] || [ "$(curl -s -o /dev/null -w "%{http_code}" /dev/null 2>/dev/null)" = "" ]; then true; fi check "dup email admin" 409 POST "/auth/register" "${J[@]}" -d '{"username":"qauniq99","password":"pass1234","firstName":"Q","lastName":"A","email":"admin@petshop.com"}' check "dup coupon code" 400 POST "/coupons" "${A[@]}" "${J[@]}" -d '{"couponCode":"WELCOME10","discountType":"PERCENTAGE","discountValue":10}' echo "--- One-to-many counts ---" check "sale 1 items" 200 GET "/sales/1" "${A[@]}" check_true "sale has >= 1 item" '(.items | length) >= 1' check "service 1 species" 200 GET "/services/1" check_true "service has >= 1 species" '(.species | length) >= 1' check "products page size 5" 200 GET "/products?size=5" check_true "content length <= 5" '(.content | length) <= 5' check_true "content length = min(5, total)" '(.content | length) == (if .totalElements < 5 then .totalElements else 5 end)' echo "--- Pagination math ---" check "pagination check" 200 GET "/products?page=0&size=10" check_field "page number is 0" '.number' '0' check_field "page size is 10" '.size' '10' check "products default page" 200 GET "/products" TOTAL=$(jq -r '.totalElements' /tmp/qa_body.json) SIZE=$(jq -r '.size' /tmp/qa_body.json) PAGES=$(jq -r '.totalPages' /tmp/qa_body.json) EXPECTED_PAGES=$(( (TOTAL + SIZE - 1) / SIZE )) if [ "$PAGES" = "$EXPECTED_PAGES" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: totalPages=$PAGES expected=$EXPECTED_PAGES (total=$TOTAL size=$SIZE)"; fi check "activity logs limit 3" 200 GET "/activity-logs?limit=3" "${A[@]}" check_true "logs length <= 3" 'length <= 3' echo "--- Cart cardinality ---" curl -s -o /dev/null -X DELETE "$BASE/cart" "${C[@]}" 2>/dev/null curl -s -o /dev/null -X POST "$BASE/cart/add" "${C[@]}" "${J[@]}" -d '{"prodId":1,"storeId":1,"quantity":1}' 2>/dev/null check "cart after add 1" 200 GET "/cart?storeId=1" "${C[@]}" CART_LEN1=$(jq '.items | length' /tmp/qa_body.json 2>/dev/null) curl -s -o /dev/null -X POST "$BASE/cart/add" "${C[@]}" "${J[@]}" -d '{"prodId":2,"storeId":1,"quantity":1}' 2>/dev/null check "cart after add 2" 200 GET "/cart?storeId=1" "${C[@]}" CART_LEN2=$(jq '.items | length' /tmp/qa_body.json 2>/dev/null) curl -s -o /dev/null -X POST "$BASE/cart/add" "${C[@]}" "${J[@]}" -d '{"prodId":1,"storeId":1,"quantity":1}' 2>/dev/null check "cart after re-add 1" 200 GET "/cart?storeId=1" "${C[@]}" CART_LEN3=$(jq '.items | length' /tmp/qa_body.json 2>/dev/null) if [ "$CART_LEN2" = "2" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: cart should have 2 items after adding 2 products, got $CART_LEN2"; fi if [ "$CART_LEN3" = "2" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: cart should still have 2 items (qty merged), got $CART_LEN3"; fi curl -s -o /dev/null -X DELETE "$BASE/cart" "${C[@]}" 2>/dev/null echo "--- Availability cardinality ---" check "avail no dups" 200 GET "/appointments/availability?storeId=1&serviceId=1&date=2027-09-01" "${A[@]}" check_true "no duplicate times" '[. | group_by(.) | all(length == 1)]' ############################################################################### echo "" echo "========== T — TIME ==========" ############################################################################### echo "--- Timestamp freshness ---" NOW_EPOCH=$(date -u +%s) check "create product" 201 POST "/products" "${A[@]}" "${J[@]}" -d '{"prodName":"QA_TIME_TEST","prodPrice":5.55,"categoryId":1}' TPROD_ID=$(jq -r '.prodId // empty' /tmp/qa_body.json) [ -n "$TPROD_ID" ] && CLEANUP_PRODUCT_IDS+=("$TPROD_ID") if [ -n "$TPROD_ID" ]; then check "get created product" 200 GET "/products/$TPROD_ID" CREATED_AT=$(jq -r '.createdAt // empty' /tmp/qa_body.json) if [ -n "$CREATED_AT" ]; then CREATED_EPOCH=$(date -u -d "$CREATED_AT" +%s 2>/dev/null || echo "0") DIFF=$((NOW_EPOCH - CREATED_EPOCH)) if [ "$DIFF" -ge "-10" ] && [ "$DIFF" -le "120" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: createdAt not recent (diff=${DIFF}s)"; fi else FAIL=$((FAIL+1)); echo "FAIL: createdAt is null" fi check "update product" 200 PUT "/products/$TPROD_ID" "${A[@]}" "${J[@]}" -d "{\"prodName\":\"QA_TIME_UPD\",\"prodPrice\":6.66,\"categoryId\":1}" check "get updated product" 200 GET "/products/$TPROD_ID" check_true "updatedAt >= createdAt" '(.updatedAt // .createdAt) >= .createdAt' fi check "create sale" 201 POST "/sales" "${S[@]}" "${J[@]}" -d '{"storeId":1,"paymentMethod":"Cash","channel":"IN_STORE","items":[{"prodId":2,"quantity":1}]}' TSALE_ID=$(jq -r '.saleId // empty' /tmp/qa_body.json) [ -n "$TSALE_ID" ] && CLEANUP_SALE_IDS+=("$TSALE_ID") if [ -n "$TSALE_ID" ]; then check "get sale" 200 GET "/sales/$TSALE_ID" "${A[@]}" check_true "saleDate has T (recent)" '.saleDate | test("T")' fi check "conversations for chat" 200 GET "/chat/conversations" "${C[@]}" CHAT_CONV=$(jq -r '.[0].conversationId // empty' /tmp/qa_body.json) if [ -n "$CHAT_CONV" ]; then curl -s -o /dev/null -X POST "$BASE/chat/conversations/$CHAT_CONV/messages" "${C[@]}" "${J[@]}" -d '{"content":"QA time test message"}' 2>/dev/null check "get chat messages" 200 GET "/chat/conversations/$CHAT_CONV/messages" "${C[@]}" LAST_TS=$(jq -r '.[-1].timestamp // empty' /tmp/qa_body.json) if [ -n "$LAST_TS" ]; then MSG_EPOCH=$(date -u -d "$LAST_TS" +%s 2>/dev/null || echo "0") MDIFF=$((NOW_EPOCH - MSG_EPOCH)) if [ "$MDIFF" -ge "-10" ] && [ "$MDIFF" -le "300" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: chat message timestamp not recent (diff=${MDIFF}s)"; fi fi fi echo "--- Temporal constraints ---" TODAY=$(date -u +%Y-%m-%d) check "scheduled appts" 200 GET "/appointments?status=Scheduled&size=50" "${A[@]}" check_true "scheduled dates >= today" "[.content[].appointmentDate] | all(. >= \"$TODAY\")" check "coupons temporal" 200 GET "/coupons" "${A[@]}" NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%S) check_true "active coupons endsAt valid" "[.[] | select(.endsAt != null) | .endsAt] | all(. >= \"$NOW_ISO\" or . == null)" echo "--- Sequencing ---" if [ -n "${CHAT_CONV:-}" ]; then check "chat sequence" 200 GET "/chat/conversations/$CHAT_CONV/messages" "${C[@]}" check_true "chat timestamps non-decreasing" '. as $a | [range(1;length)] | all(. as $i | $a[$i].timestamp >= $a[$i-1].timestamp)' fi check "activity log sequence" 200 GET "/activity-logs?limit=20" "${A[@]}" check_true "logs non-increasing" '. as $a | [range(1;length)] | all(. as $i | $a[$i].logTimestamp <= $a[$i-1].logTimestamp)' echo "--- Token timing ---" FRESH_TOKEN=$(curl -s -X POST "$BASE/auth/login" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' | jq -r '.token') check "fresh token works" 200 GET "/auth/me" -H "Authorization: Bearer $FRESH_TOKEN" curl -s -o /dev/null -X POST "$BASE/auth/logout" -H "Authorization: Bearer $FRESH_TOKEN" 2>/dev/null LOGOUT_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE/auth/me" -H "Authorization: Bearer $FRESH_TOKEN" 2>/dev/null) if [ "$LOGOUT_CODE" = "401" ]; then PASS=$((PASS+1)) else FAIL=$((FAIL+1)); echo "FAIL: old token after logout — expected 401 got $LOGOUT_CODE"; fi ADMIN_TOKEN=$(curl -s -X POST "$BASE/auth/login" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' | jq -r '.token') A=(-H "Authorization: Bearer $ADMIN_TOKEN") echo "" echo "=========================================" echo "CORRECT RESULTS: $PASS passed, $FAIL failed" echo "========================================="