feat: نقطة الرحلة المعلّقة للتقييم + اختبار E2E شامل

نقطة التقييم عند الفتح (طلب المالك): GET /trips/rating/pending يرجع أحدث
رحلة منتهية لم يقيّمها المستخدم (سائقاً أو راكباً) + العدد. التطبيق يناديها
عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة. TripsService.
finishedForParticipant يجلب المنتهية، و RatingsService.pendingRating يطرح
المقيَّمة باستعلام واحد.

scripts/e2e-test.mjs: اختبار شامل متكامل لكل وظائف الباك إند —
- تسجيل الدخول/الحساب + تطبيع الهاتف (نفس الرقم بصيغتين = حساب واحد)
- إعداد السائق: تقديم + ملف (رقم وطني مشفّر) + مركبة + اعتماد + إعادة دخول
  للحصول على دور driver في التوكن
- الرصيد التشغيلي (مكافأة التسجيل) + عدم الحجب
- اتصال السائق ورفع الموقع (Redis) + المطابقة + /trips/available
- طلب رحلة (تسعير + عرض) + كل الحالات (assigned→...→completed→paid)
- WebSocket: اتصال JWT + رصد trip:update (اختياري، يتخطّى إن غاب
  socket.io-client)
- خصم العمولة من الرصيد التشغيلي عند الإنهاء
- تسوية المحفظة عند paid (خصم الأجرة من الراكب)
- التقييم: رحلة معلّقة → تقييم الطرفين → تُمسح المعلّقة + منع التكرار
- السحب بـOTP خطوتين (طلب بلا خصم → تأكيد بالرمز → حجز)

أوامر التشغيل في docs/15 (مع/بلا WebSocket). يتطلّب OTP_DEV_MODE=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 20:05:29 +03:00
co-authored by Claude Opus 4.8
parent a58f611063
commit feb2a9663d
6 changed files with 387 additions and 2 deletions
+300
View File
@@ -0,0 +1,300 @@
// اختبار شامل متكامل لكل وظائف الباك إند (docs/17).
// يغطّي: تسجيل الدخول/الحساب · إعداد السائق (ملف + مركبة) · طلب رحلة ·
// المطابقة عبر Redis · القبول الذرّي · كل حالات الرحلة · تتبّع WebSocket ·
// التسوية المالية (محفظة) · الرصيد التشغيلي والعمولة · التقييم للطرفين ·
// نقطة «رحلة معلّقة للتقييم عند الفتح» · السحب بـOTP.
//
// يستخدم fetch المدمج. اختبار الـWebSocket اختياري: يعمل إن توفّر
// socket.io-client، ويُتخطّى بتحذير إن غاب.
//
// التشغيل (مع WebSocket):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -e WS=http://tripz-api:4010 -v /home/tripz-llc/backend/scripts:/s \
// node:22-alpine sh -c "cd /tmp && npm i socket.io-client --silent 2>/dev/null; \
// NODE_PATH=/tmp/node_modules node /s/e2e-test.mjs"
//
// التشغيل (REST فقط، بلا WebSocket):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs
//
// يتطلّب OTP_DEV_MODE=true (الرمز الثابت 1234).
const BASE = process.env.BASE || 'http://localhost:4010/api';
const WS = process.env.WS || BASE.replace(/\/api$/, '');
const TENANT = process.env.TENANT || 'siro';
const CODE = '1234';
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
const DEST = { lat: 31.9800, lng: 35.8700 };
let passed = 0;
let failed = 0;
const failures = [];
function check(name, cond, detail) {
if (cond) {
passed++;
console.log(` ✅ ${name}`);
} else {
failed++;
failures.push(name + (detail ? ` — ${detail}` : ''));
console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`);
}
}
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body, opts = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* غير JSON */ }
if (!res.ok && !opts.allowError) {
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
}
return { status: res.status, body: json, ok: res.ok };
}
async function login(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
return { token: r.body.access_token, userId: r.body.user?.id, role: r.body.user?.role };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const uniqPhone = (p) => `${p}${String(Date.now()).slice(-6)}`;
// ---- اختبار WebSocket اختياري ----
async function tryWebSocket(riderToken, tripId) {
let ioClient;
try {
ioClient = (await import('socket.io-client')).io;
} catch {
console.log(' ⚠️ socket.io-client غير متوفّر — تخطّي اختبار WebSocket (راجع أمر التشغيل)');
return null;
}
return new Promise((resolve) => {
const events = { connected: false, tripUpdate: false, driverLocation: false };
const socket = ioClient(WS, { auth: { token: riderToken }, transports: ['websocket'], timeout: 5000 });
const done = () => { try { socket.close(); } catch {} resolve(events); };
socket.on('connect', () => {
events.connected = true;
socket.emit('trip:join', { tripId });
});
socket.on('trip:update', () => { events.tripUpdate = true; });
socket.on('driver:location', () => { events.driverLocation = true; });
socket.on('connect_error', () => done());
setTimeout(done, 8000); // نافذة رصد الأحداث
});
}
async function main() {
console.log(`\n🧪 اختبار Tripz الشامل على ${BASE}\n`);
// ═══ 1. تسجيل الدخول / إنشاء الحساب ═══
console.log('1) تسجيل الدخول وإنشاء الحساب (هاتف + OTP)');
const riderPhone = uniqPhone('07900');
const rider = await login(riderPhone);
check('الراكب سجّل دخوله وحصل على توكن', !!rider.token);
check('حساب جديد بدور rider افتراضياً', rider.role === 'rider', `role=${rider.role}`);
// نفس الرقم بصيغة مختلفة → نفس الحساب (تطبيع D1)
const riderAgain = await api('POST', '/auth/verify-otp', null, {
phone: '0' + riderPhone.slice(1), code: CODE,
}, { allowError: true });
check('تطبيع الهاتف: نفس الرقم بصيغ مختلفة = نفس المستخدم',
riderAgain.body.user?.id === rider.userId);
const driverPhone = uniqPhone('07955');
let driver = await login(driverPhone);
check('السائق سجّل دخوله', !!driver.token);
// ═══ 2. إعداد السائق (ملف + مركبة + اعتماد) ═══
console.log('\n2) إعداد السائق: تقديم + ملف + مركبة + اعتماد');
const applied = await api('POST', '/drivers/apply', driver.token, {
vehicle_make: 'Toyota', service_class: 'economy',
});
const driverId = applied.body.id;
check('تقديم السائق أنشأ سجلاً', !!driverId);
await api('PATCH', '/drivers/profile', driver.token, {
gender: 'male', national_number: uniqPhone('99'), name_arabic: 'سائق تجريبي',
license_type: 'private',
});
check('تحديث ملف السائق (رقم وطني مشفّر + فهرس أعمى)', true);
const vehicle = await api('POST', '/vehicles', driver.token, {
make: 'Toyota', model: 'Corolla', year: 2020, color: 'أبيض', color_hex: '#FFFFFF',
plate: uniqPhone('PL'),
});
check('إنشاء مركبة', !!vehicle.body.id);
check('أول مركبة تصير الافتراضية تلقائياً', vehicle.body.is_default === true);
await api('PATCH', `/drivers/${driverId}/approve`, driver.token, {});
// الدور صار driver بعد apply — يجب إعادة الدخول ليحمل التوكن الدور الجديد
driver = await login(driverPhone);
check('بعد الاعتماد وإعادة الدخول: الدور driver في التوكن', driver.role === 'driver', `role=${driver.role}`);
// ═══ 3. الرصيد التشغيلي (مكافأة التسجيل) ═══
console.log('\n3) الرصيد التشغيلي للسائق');
const credit0 = await api('GET', '/credit', driver.token);
check('السائق بدأ برصيد تشغيلي (مكافأة تسجيل > 0)', Number(credit0.body.balance) > 0,
`balance=${credit0.body.balance}`);
check('السائق غير محجوب', credit0.body.blocked === false);
// ═══ 4. اتصال السائق + رفع الموقع (Redis) ═══
console.log('\n4) اتصال السائق ورفع الموقع (Redis + المطابقة)');
await api('PATCH', '/drivers/status', driver.token, { online: true });
await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 0, heading: 0 });
check('السائق متصل ورفع موقعه', true);
// ═══ 5. المحفظة (شحن للتسوية لاحقاً) ═══
console.log('\n5) المحفظة');
await api('POST', '/wallet/topup', rider.token, { amount: 50 });
const wallet0 = await api('GET', '/wallet', rider.token);
check('شحن محفظة الراكب', Number(wallet0.body.balance) >= 50, `balance=${wallet0.body.balance}`);
// ═══ 6. طلب رحلة (تسعير + مطابقة + عرض) ═══
console.log('\n6) طلب رحلة (تسعير + مطابقة عبر Redis)');
const reqTrip = await api('POST', '/trips', rider.token, {
origin: ORIGIN, destination: DEST, service_class: 'economy', payment_method: 'wallet',
});
const tripId = reqTrip.body.trip?.id;
check('إنشاء الرحلة', !!tripId);
check('الرحلة بحالة searching', reqTrip.body.trip?.status === 'searching');
check('حُسبت أجرة مقفولة', reqTrip.body.trip?.quoted_fare != null,
`fare=${reqTrip.body.trip?.quoted_fare}`);
check('عُرضت على سائق قريب (مطابقة Redis نجحت)', reqTrip.body.offeredDrivers >= 1,
`offered=${reqTrip.body.offeredDrivers}`);
// السائق يرى الرحلة في المتاحة
const available = await api('GET', '/trips/available', driver.token);
check('السائق يرى الرحلة في /trips/available', Array.isArray(available.body) &&
available.body.some((t) => t.tripId === tripId || t.id === tripId));
// ═══ 7. WebSocket (يُبدأ قبل الانتقالات لرصد الأحداث) ═══
console.log('\n7) تتبّع WebSocket (اختياري)');
const wsPromise = tryWebSocket(rider.token, tripId);
await sleep(1500); // مهلة ليتّصل السوكت قبل الانتقالات، وإلا فاتته الأحداث
// ═══ 8. القبول + كل حالات الرحلة ═══
console.log('\n8) القبول وكل حالات الرحلة');
const accepted = await api('POST', `/trips/${tripId}/accept`, driver.token, {});
check('السائق قبل الرحلة', accepted.ok);
const afterAccept = await api('GET', `/trips/${tripId}`, driver.token);
check('الحالة صارت assigned', afterAccept.body.status === 'assigned', `status=${afterAccept.body.status}`);
const transitions = ['driver_arriving', 'driver_arrived', 'in_progress', 'completed'];
for (const st of transitions) {
// نبضة موقع قبل الوصول (لكشف تزوير الوصول — H/E)
if (st === 'driver_arrived') {
await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 5 });
}
const r = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: st }, { allowError: true });
check(`الانتقال إلى ${st}`, r.ok, r.ok ? '' : JSON.stringify(r.body).slice(0, 120));
await sleep(150);
}
const afterComplete = await api('GET', `/trips/${tripId}`, driver.token);
check('الرحلة مكتملة', afterComplete.body.status === 'completed');
check('طوابع دقيقة: started_at مضبوط', !!afterComplete.body.started_at);
check('السعر مفصول: price_for_passenger و price_for_driver',
afterComplete.body.price_for_passenger != null && afterComplete.body.price_for_driver != null);
// ═══ 9. العمولة خُصمت من الرصيد التشغيلي عند الإنهاء ═══
console.log('\n9) العمولة من الرصيد التشغيلي (عند الإنهاء)');
const credit1 = await api('GET', '/credit', driver.token);
const commission = Number(afterComplete.body.commission_amount ?? 0);
if (commission > 0) {
check('نقص الرصيد التشغيلي بمقدار العمولة',
Math.abs((Number(credit0.body.balance) - Number(credit1.body.balance)) - commission) < 0.01,
`${credit0.body.balance} → ${credit1.body.balance}, عمولة=${commission}`);
} else {
check('لا عمولة على هذه التعرفة (تخطّي)', true);
}
// ═══ 10. الدفع (تسوية المحفظة عند paid) ═══
console.log('\n10) الدفع وتسوية المحفظة');
const paidRes = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: 'paid' }, { allowError: true });
check('الانتقال إلى paid', paidRes.ok, paidRes.ok ? '' : JSON.stringify(paidRes.body).slice(0, 120));
await sleep(200);
const walletAfter = await api('GET', '/wallet', rider.token);
const fare = Number(afterComplete.body.price_for_passenger ?? 0);
check('خُصمت الأجرة من محفظة الراكب',
Math.abs((Number(wallet0.body.balance) - Number(walletAfter.body.balance)) - fare) < 0.01,
`${wallet0.body.balance} → ${walletAfter.body.balance}, أجرة=${fare}`);
// ═══ 11. التقييم: نقطة «رحلة معلّقة عند الفتح» ═══
console.log('\n11) التقييم (رحلة معلّقة عند الفتح + تقييم الطرفين)');
const pendingRider = await api('GET', '/trips/rating/pending', rider.token);
check('الراكب لديه رحلة معلّقة للتقييم عند الفتح', pendingRider.body.pending?.tripId === tripId,
`pending=${JSON.stringify(pendingRider.body.pending)}`);
check('دور الراكب في التقييم = rider', pendingRider.body.pending?.role === 'rider');
const pendingDriver = await api('GET', '/trips/rating/pending', driver.token);
check('السائق لديه رحلة معلّقة للتقييم', pendingDriver.body.pending?.tripId === tripId);
check('دور السائق في التقييم = driver', pendingDriver.body.pending?.role === 'driver');
await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 5, comment: 'ممتاز' });
check('الراكب قيّم السائق', true);
await api('POST', `/trips/${tripId}/rate`, driver.token, { stars: 4 });
check('السائق قيّم الراكب', true);
const pendingAfter = await api('GET', '/trips/rating/pending', rider.token);
check('بعد التقييم: لا رحلة معلّقة للراكب', pendingAfter.body.pending === null);
const dupRate = await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 3 }, { allowError: true });
check('منع التقييم المزدوج', !dupRate.ok && dupRate.status === 400);
// ═══ 12. السحب بـOTP (تدفّق خطوتين) ═══
console.log('\n12) سحب أرباح السائق (OTP خطوتين)');
// السائق يشحن محفظة أرباحه أولاً ليكون له رصيد للسحب
await api('POST', '/wallet/topup', driver.token, { amount: 20 });
const payoutReq = await api('POST', '/payouts/request', driver.token,
{ amount: 10, channel: 'cliq' }, { allowError: true });
check('طلب السحب أرسل رمزاً بلا خصم', payoutReq.ok && payoutReq.body.otp_sent === true,
JSON.stringify(payoutReq.body).slice(0, 120));
const payoutId = payoutReq.body.payout?.id;
const devCode = payoutReq.body.dev_code;
if (payoutId && devCode) {
const walletBeforeConfirm = await api('GET', '/wallet', driver.token);
const confirm = await api('POST', `/payouts/${payoutId}/confirm`, driver.token,
{ code: String(devCode) }, { allowError: true });
check('تأكيد السحب بالرمز نجح', confirm.ok, JSON.stringify(confirm.body).slice(0, 120));
const walletAfterConfirm = await api('GET', '/wallet', driver.token);
check('حُجز مبلغ السحب من محفظة السائق عند التأكيد',
Math.abs((Number(walletBeforeConfirm.body.balance) - Number(walletAfterConfirm.body.balance)) - 10) < 0.01,
`${walletBeforeConfirm.body.balance} → ${walletAfterConfirm.body.balance}`);
} else {
check('dev_code متوفّر للتأكيد', false, 'لا يوجد dev_code — تأكّد من OTP_DEV_MODE=true');
}
// ═══ نتيجة WebSocket ═══
console.log('\n13) نتيجة WebSocket');
const ws = await wsPromise;
if (ws) {
check('اتصال WebSocket بمصادقة JWT', ws.connected);
check('استقبال أحداث trip:update أثناء الرحلة', ws.tripUpdate);
// driver:location قد لا يُبثّ إن لم تكن الحركة ذات دلالة — تحذير لا فشل
if (!ws.driverLocation) console.log(' ⚠️ لم تُرصد driver:location (قد لا تكون حركة ذات دلالة)');
}
// ═══ الخلاصة ═══
console.log('\n' + '═'.repeat(50));
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
if (failed > 0) {
console.log('\nالإخفاقات:');
for (const f of failures) console.log(` ❌ ${f}`);
}
console.log('═'.repeat(50) + '\n');
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('\n💥 توقّف الاختبار بخطأ غير متوقّع:', e.message);
console.error(e.stack?.split('\n').slice(0, 4).join('\n'));
process.exit(1);
});
@@ -1,4 +1,4 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RatingsService } from './ratings.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -11,6 +11,15 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
export class RatingsController {
constructor(private readonly ratings: RatingsService) {}
/**
* رحلة منتهية تنتظر تقييم هذا المستخدم (docs/17 — التقييم). يناديها التطبيق
* عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة.
*/
@Get('rating/pending')
pending(@CurrentUser() user: AuthUser) {
return this.ratings.pendingRating(user.tenantId, user.userId);
}
@Post(':id/rate')
rate(
@CurrentUser() user: AuthUser,
+41 -1
View File
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { Rating } from './entities/rating.entity';
import { TripsService } from '../trips/trips.service';
import { DriversService } from '../drivers/drivers.service';
@@ -22,6 +22,46 @@ export class RatingsService {
private readonly aggregate: RatingAggregateService,
) {}
/**
* الرحلة المنتهية غير المقيَّمة من هذا المستخدم (docs/17 — التقييم).
*
* يستدعيها التطبيق عند كل فتح (سائقاً أو راكباً): إن رجعت رحلة، يفتح شاشة
* التقييم إجبارياً قبل السماح بطلب/قبول رحلة جديدة. يرجع الأحدث + العدد
* الكلّي للمعلَّقات.
*/
async pendingRating(tenantId: string, userId: string) {
const driver = await this.drivers.findByUser(tenantId, userId);
const finished = await this.trips.finishedForParticipant(tenantId, userId, driver?.id ?? null);
if (finished.length === 0) return { pending: null, count: 0 };
// ما قيّمه المستخدم من هذه الرحلات — استعلام واحد بدل واحد لكل رحلة.
const rated = await this.repo.find({
where: {
tenant_id: tenantId,
by_user_id: userId,
trip_id: In(finished.map((t) => t.id)),
},
select: { trip_id: true },
});
const ratedIds = new Set(rated.map((r) => r.trip_id));
const unrated = finished.filter((t) => !ratedIds.has(t.id));
if (unrated.length === 0) return { pending: null, count: 0 };
const trip = unrated[0]; // الأحدث (مرتَّبة تنازلياً)
const asDriver = !!driver && trip.driver_id === driver.id && trip.rider_id !== userId;
return {
count: unrated.length,
pending: {
tripId: trip.id,
role: asDriver ? 'driver' : 'rider', // مَن يقيّم مَن
fare: trip.final_fare == null ? null : Number(trip.final_fare),
currency: trip.currency ?? null,
completedAt: trip.completed_at,
distanceKm: trip.distance_km == null ? null : Number(trip.distance_km),
},
};
}
async rate(
tenantId: string,
tripId: string,
@@ -93,6 +93,28 @@ export class TripsService {
});
}
/**
* الرحلات المنتهية (completed/paid) التي شارك فيها المستخدم راكباً أو سائقاً
* — أساس كشف الرحلة غير المقيَّمة عند فتح التطبيق (docs/17 — G6/التقييم).
*/
finishedForParticipant(
tenantId: string,
riderId: string,
driverId: string | null,
limit = 20,
): Promise<Trip[]> {
const qb = this.trips
.createQueryBuilder('t')
.where('t.tenant_id = :tenantId', { tenantId })
.andWhere('t.status IN (:...done)', { done: ['completed', 'paid'] });
if (driverId) {
qb.andWhere('(t.rider_id = :riderId OR t.driver_id = :driverId)', { riderId, driverId });
} else {
qb.andWhere('t.rider_id = :riderId', { riderId });
}
return qb.orderBy('t.completed_at', 'DESC').take(limit).getMany();
}
listForDriver(tenantId: string, driverId: string): Promise<Trip[]> {
return this.trips.find({
where: { tenant_id: tenantId, driver_id: driverId },