diff --git a/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php new file mode 100644 index 00000000..6306a00f --- /dev/null +++ b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php @@ -0,0 +1,41 @@ +hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->char('order_uuid', 36)->nullable()->after('customer_uuid'); + $table->index(['customer_uuid', 'subject_uuid', 'order_uuid'], 'reviews_customer_subject_order_index'); + }); + } + + public function down(): void + { + $connection = config('storefront.connection.db'); + + if (!Schema::connection($connection)->hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->dropIndex('reviews_customer_subject_order_index'); + $table->dropColumn('order_uuid'); + }); + } +}; diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dd51ba78..8c242db8 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -11,6 +11,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\ReviewEligibility; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -216,6 +217,29 @@ public function find($id) return new StorefrontReview($review); } + /** + * Whether the signed-in customer can review a store or product, and why not. + * + * The app calls this before showing "Write a review", so a customer who has not completed + * an order, or has already reviewed every completed one, sees an explanation instead. + */ + public function eligibility(Request $request) + { + $subjectId = $request->input('subject'); + if (!is_string($subjectId) || $subjectId === '') { + return response()->error('A subject is required.'); + } + + $subject = Utils::resolveSubject($subjectId); + if (!$subject || !$this->subjectBelongsToContext($subject)) { + return response()->error('Invalid subject for review'); + } + + $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); + + return response()->json($eligibility->toArray()); + } + /** * Create a review. * @@ -238,9 +262,15 @@ public function create(CreateReviewRequest $request) return response()->error('Invalid subject for review'); } + $eligibility = ReviewEligibility::check($customer, $subject, $request->input('order')); + if (!$eligibility->allowed) { + return response()->json(['error' => $eligibility->message(), 'reason' => $eligibility->reason], 403); + } + $review = Review::create([ 'created_by_uuid' => $customer->user_uuid, 'customer_uuid' => $customer->uuid, + 'order_uuid' => $eligibility->order->uuid, 'subject_uuid' => $subject->uuid, 'subject_type' => Utils::getMutationType($subject), 'rating' => $request->input('rating'), diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php index 470ab7bf..596a09c7 100644 --- a/server/src/Http/Requests/CreateReviewRequest.php +++ b/server/src/Http/Requests/CreateReviewRequest.php @@ -31,11 +31,14 @@ public function rules() // — as a 500, before the controller's own "Invalid subject for review" guard // could run. That guard was unreachable for the commonest way to get it wrong. return [ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ]; } } diff --git a/server/src/Http/Resources/Review.php b/server/src/Http/Resources/Review.php index 240b212b..734e50b6 100644 --- a/server/src/Http/Resources/Review.php +++ b/server/src/Http/Resources/Review.php @@ -3,6 +3,8 @@ namespace Fleetbase\Storefront\Http\Resources; use Fleetbase\Http\Resources\FleetbaseResource; +use Fleetbase\Storefront\Models\Product; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Http; class Review extends FleetbaseResource @@ -17,17 +19,21 @@ class Review extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'subject_id' => $this->subject->id, - 'rating' => $this->rating, - 'content' => $this->content, - 'customer' => new ReviewCustomer($this->customer), - 'slug' => $this->slug, - 'photos' => $this->mapPhotos($this->photos), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'subject_id' => $this->when(Http::isInternalRequest(), $this->subject?->id, $this->subject?->public_id), + 'subject_type' => $this->subject instanceof Product ? 'product' : 'store', + 'order_uuid' => $this->when(Http::isInternalRequest(), $this->order_uuid), + 'verified' => !empty($this->order_uuid), + 'is_mine' => $this->customer_uuid !== null && $this->customer_uuid === static::viewerCustomerUuid(), + 'rating' => $this->rating, + 'content' => $this->content, + 'customer' => new ReviewCustomer($this->customer), + 'slug' => $this->slug, + 'photos' => $this->mapPhotos($this->photos), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } @@ -43,4 +49,20 @@ public function mapPhotos($photos = []) ]; }); } + + /** + * The uuid of the customer making the request, looked up once per request so that a + * page of reviews does not resolve the customer token for every review. + */ + public static function viewerCustomerUuid(): ?string + { + $request = request(); + $key = 'storefront.review_viewer_uuid'; + + if (!$request->attributes->has($key)) { + $request->attributes->set($key, Http::isInternalRequest() ? null : Storefront::getCustomerFromToken()?->uuid); + } + + return $request->attributes->get($key); + } } diff --git a/server/src/Http/Resources/ReviewCustomer.php b/server/src/Http/Resources/ReviewCustomer.php index 590cd748..b8dc6143 100644 --- a/server/src/Http/Resources/ReviewCustomer.php +++ b/server/src/Http/Resources/ReviewCustomer.php @@ -17,13 +17,17 @@ class ReviewCustomer extends FleetbaseResource */ public function toArray($request) { + $internal = Http::isInternalRequest(); + + // Reviews are public: customers see each other's first name and last initial only, + // never their contact details. return [ 'id' => $this->when(Http::isInternalRequest(), $this->id, Str::replaceFirst('contact', 'customer', $this->public_id)), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'name' => $this->name, - 'email' => $this->email, - 'phone' => $this->phone, + 'name' => $internal ? $this->name : static::publicName($this->name), + 'email' => $this->when($internal, $this->email), + 'phone' => $this->when($internal, $this->phone), 'photo_url' => $this->photo_url, 'reviews_count' => $this->resource->reviews()->count(), 'uploads_count' => $this->resource->reviewUploads()->count(), @@ -32,4 +36,21 @@ public function toArray($request) 'updated_at' => $this->updated_at, ]; } + + /** + * "Ada B." from "Ada Buyer"; a single name is shown as is. + */ + public static function publicName(?string $name): ?string + { + $parts = preg_split('/\s+/u', trim((string) $name), -1, PREG_SPLIT_NO_EMPTY); + if (!$parts) { + return null; + } + + if (count($parts) === 1) { + return $parts[0]; + } + + return $parts[0] . ' ' . mb_strtoupper(mb_substr(end($parts), 0, 1)) . '.'; + } } diff --git a/server/src/Models/Review.php b/server/src/Models/Review.php index 178efbd9..7ec470cd 100644 --- a/server/src/Models/Review.php +++ b/server/src/Models/Review.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Models; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\Models\File; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -45,6 +46,7 @@ class Review extends StorefrontModel protected $fillable = [ 'created_by_uuid', 'customer_uuid', + 'order_uuid', 'subject_uuid', 'subject_type', 'rating', @@ -57,7 +59,9 @@ class Review extends StorefrontModel * * @var array */ - protected $casts = []; + protected $casts = [ + 'rating' => 'integer', + ]; /** * Dynamic attributes that are appended to object. @@ -89,6 +93,14 @@ public function customer() return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Customer::class); } + /** + * The completed order this review was written for. + */ + public function order() + { + return $this->belongsTo(Order::class, 'order_uuid', 'uuid'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ diff --git a/server/src/Support/ReviewEligibility.php b/server/src/Support/ReviewEligibility.php new file mode 100644 index 00000000..7c7fd966 --- /dev/null +++ b/server/src/Support/ReviewEligibility.php @@ -0,0 +1,136 @@ + + */ + public const MESSAGES = [ + self::SIGN_IN_REQUIRED => 'Sign in to write a review.', + self::UNSUPPORTED_SUBJECT => 'Only stores and products can be reviewed.', + self::NO_COMPLETED_ORDER => 'You can review this after an order with it is completed.', + self::ALREADY_REVIEWED => 'You have already reviewed this for your completed orders.', + ]; + + public function __construct( + public bool $allowed, + public ?string $reason = null, + public ?Order $order = null, + public ?Review $review = null, + ) { + } + + /** + * Check whether the customer can review the subject, optionally for one specific order. + * + * When no order is given, the most recent completed order that has not been reviewed for + * this subject is used. + */ + public static function check(?Contact $customer, mixed $subject, ?string $orderId = null): self + { + if (!$customer) { + return new self(false, self::SIGN_IN_REQUIRED); + } + + $query = static::completedOrdersFor($customer, $subject); + if (!$query) { + return new self(false, self::UNSUPPORTED_SUBJECT); + } + + if ($orderId) { + $query->where(fn ($orderQuery) => $orderQuery->where('public_id', $orderId)->orWhere('uuid', $orderId)); + } + + $orders = $query->orderByDesc('created_at')->limit(static::ORDER_LOOKBACK)->get(); + if ($orders->isEmpty()) { + return new self(false, self::NO_COMPLETED_ORDER); + } + + $reviews = Review::where('customer_uuid', $customer->uuid) + ->where('subject_uuid', $subject->uuid) + ->whereIn('order_uuid', $orders->pluck('uuid')) + ->get() + ->keyBy('order_uuid'); + + $unreviewed = $orders->first(fn ($order) => !$reviews->has($order->uuid)); + if (!$unreviewed) { + $latest = $orders->first(); + + return new self(false, self::ALREADY_REVIEWED, $latest, $reviews->get($latest->uuid)); + } + + return new self(true, null, $unreviewed); + } + + /** + * The customer's completed orders that include the subject, or null when the subject + * cannot be reviewed. + */ + public static function completedOrdersFor(Contact $customer, mixed $subject): ?Builder + { + $query = Order::query() + ->where('customer_uuid', $customer->uuid) + ->whereIn('status', static::COMPLETED_STATUSES); + + if ($subject instanceof Store) { + return $query->where('meta->storefront_id', $subject->public_id); + } + + if ($subject instanceof Product) { + return $query->whereIn( + 'payload_uuid', + Entity::query()->select('payload_uuid')->where('internal_id', $subject->public_id)->whereNotNull('payload_uuid') + ); + } + + return null; + } + + public function message(): ?string + { + return $this->reason ? static::MESSAGES[$this->reason] : null; + } + + /** + * @return array{can_review: bool, reason: ?string, message: ?string, order: ?string, review: ?string} + */ + public function toArray(): array + { + return [ + 'can_review' => $this->allowed, + 'reason' => $this->reason, + 'message' => $this->message(), + 'order' => $this->order?->public_id, + 'review' => $this->review?->public_id, + ]; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..65685ecd 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -88,9 +88,10 @@ function ($router) { $router->group(['prefix' => 'reviews'], function () use ($router) { $router->get('/', 'ReviewController@query'); $router->get('count', 'ReviewController@count'); + $router->get('eligibility', 'ReviewController@eligibility'); $router->get('{id}', 'ReviewController@find'); $router->post('/', 'ReviewController@create'); - $router->delete('{id}', 'ReviewController@find'); + $router->delete('{id}', 'ReviewController@delete'); }); // storefront/v1/orders diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 14c86195..d823acaf 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -281,6 +281,33 @@ public function unassignCurrentOrder(): void } } +function createReviewOrderSchema(): void +{ + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('orders'); + $schema->dropIfExists('entities'); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); +} + function createReviewControllerSchema(): void { $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); @@ -292,6 +319,7 @@ function createReviewControllerSchema(): void $table->string('public_id')->nullable(); $table->string('created_by_uuid')->nullable(); $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); $table->string('subject_uuid')->nullable(); $table->string('subject_type')->nullable(); $table->integer('rating')->nullable(); @@ -637,9 +665,10 @@ function createReviewControllerSchema(): void createReviewControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['personal_access_tokens', 'files', 'contacts', 'stores'] as $table) { + foreach (['personal_access_tokens', 'files', 'contacts', 'stores', 'orders', 'entities'] as $table) { $schema->dropIfExists($table); } + createReviewOrderSchema(); $schema->create('personal_access_tokens', function ($table) { $table->increments('id'); $table->string('tokenable_type')->nullable(); @@ -728,6 +757,14 @@ function createReviewControllerSchema(): void ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'], ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'], ]); + // Two completed orders from the store and one containing the product: each order can be + // reviewed once per subject, newest first. + $connection->table('orders')->insert([ + ['uuid' => 'older_store_order_uuid', 'public_id' => 'order_older', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(3)], + ['uuid' => 'newer_store_order_uuid', 'public_id' => 'order_newer', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + ['uuid' => 'product_order_uuid', 'public_id' => 'order_product', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => 'payload_uuid', 'meta' => json_encode(['storefront_id' => 'other_store']), 'created_at' => now()->subDays(2)], + ]); + $connection->table('entities')->insert(['uuid' => 'entity_uuid', 'payload_uuid' => 'payload_uuid', 'internal_id' => 'product_abcdefgh']); $boundRequest = Request::create('/reviews'); $boundRequest->headers->set('Customer-Token', 'review-customer-secret'); $boundRequest->setLaravelSession(new Illuminate\Session\Store( @@ -801,7 +838,14 @@ public function put(string $path, string $contents, string $visibility): bool ], ]) ); - $photo = $connection->table('files')->first(); + $photo = $connection->table('files')->first(); + $alreadyReviewed = $controller->create( + Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 3, + 'content' => 'Third review', + ]) + ); session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $deleted = $controller->delete('review_owned'); @@ -814,6 +858,14 @@ public function put(string $path, string $contents, string $visibility): bool ->and($review->subject_uuid)->toBe('store_uuid') ->and($review->rating)->toBe(5) ->and($review->content)->toBe('Excellent service') + ->and($review->order_uuid)->toBe('newer_store_order_uuid') + ->and($createdProduct->resource->order_uuid)->toBe('product_order_uuid') + ->and($withPhoto->resource->order_uuid)->toBe('older_store_order_uuid') + ->and($alreadyReviewed->getStatusCode())->toBe(403) + ->and($alreadyReviewed->getData(true))->toBe([ + 'error' => 'You have already reviewed this for your completed orders.', + 'reason' => 'already_reviewed', + ]) ->and($withPhoto->resource->files)->toHaveCount(1) ->and($photo->subject_uuid)->toBe($withPhoto->resource->uuid) ->and($photo->content_type)->toBe('image/png') diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index d8840dd9..c9ad3752 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -152,12 +152,17 @@ // `subject` is required: the controller resolves it with Utils::resolveSubject(), // whose parameter is a non-nullable string, so omitting it threw a TypeError as a // 500 before the controller's own "Invalid subject for review" guard could run. + // Ratings are whole stars from 1 to 5, and a review carries at most four photos or + // videos. `order` optionally names the completed order being reviewed. ->and($reviewRules)->toBe([ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ])->and($verificationRules)->toBe([ 'mode' => 'required|in:email,sms', 'identity' => 'required', diff --git a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php index f81cfcd8..d83b7258 100644 --- a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php @@ -374,10 +374,27 @@ public function count(): int expect($data)->toMatchArray([ 'id' => 'customer_public', - 'name' => 'Ada Buyer', + 'name' => 'Ada B.', 'reviews_count' => 4, 'uploads_count' => 6, - ])->and($data)->not->toHaveKeys(['uuid', 'public_id']); + ])->and($data)->not->toHaveKeys(['uuid', 'public_id', 'email', 'phone']); + + $internal = (new ReviewCustomerResource($customer)) + ->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/review-customers')); + + expect($internal)->toMatchArray([ + 'name' => 'Ada Buyer', + 'email' => 'ada@example.test', + 'phone' => '+15550100', + ]); +}); + +test('review customers are named by first name and last initial in public', function () { + expect(ReviewCustomerResource::publicName(' ada van buyer '))->toBe('ada B.') + ->and(ReviewCustomerResource::publicName('Ada'))->toBe('Ada') + ->and(ReviewCustomerResource::publicName('Ölga ölund'))->toBe('Ölga Ö.') + ->and(ReviewCustomerResource::publicName(' '))->toBeNull() + ->and(ReviewCustomerResource::publicName(null))->toBeNull(); }); test('store hour and location resources preserve customer-facing scheduling shapes', function () { diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php index 3d55ff3a..749f9cf2 100644 --- a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php @@ -158,27 +158,33 @@ public function uri(): string 'name' => 'Ada Lovelace', ]); $review = storefrontResourceModel([ - 'id' => 4, - 'uuid' => 'review_uuid', - 'public_id' => 'review_123', + 'id' => 4, + 'uuid' => 'review_uuid', + 'public_id' => 'review_123', + 'customer_uuid' => 'customer_uuid', + 'order_uuid' => 'order_uuid', 'rating' => 5, 'content' => 'Excellent service', 'slug' => 'excellent-service', 'created_at' => '2026-01-01', 'updated_at' => '2026-01-02', ], [ - 'subject' => storefrontResourceModel(['id' => 99]), + 'subject' => storefrontResourceModel(['id' => 99, 'public_id' => 'store_123']), 'customer' => $customer, 'photos' => collect([$photo]), ]); $reviewData = (new ReviewResource($review))->toArray(setSimpleStorefrontResourceRoute('v1/storefront/reviews')); + // Public responses name the subject by its public id, never the internal row id. expect($reviewData)->toMatchArray([ - 'id' => 'review_123', - 'subject_id' => 99, - 'rating' => 5, - 'content' => 'Excellent service', + 'id' => 'review_123', + 'subject_id' => 'store_123', + 'subject_type' => 'store', + 'verified' => true, + 'is_mine' => false, + 'rating' => 5, + 'content' => 'Excellent service', ])->and($reviewData['photos'][0])->toBe([ 'id' => 'file_123', 'filename' => 'receipt.jpg', diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..46cc19cf 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -96,5 +96,7 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], - )->and(count($router->routes))->toBeGreaterThan(50); + ['GET', 'eligibility', 'ReviewController@eligibility'], + ['DELETE', '{id}', 'ReviewController@delete'], + )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Support/ReviewEligibilityTest.php b/server/tests/Unit/Support/ReviewEligibilityTest.php new file mode 100644 index 00000000..7f365af4 --- /dev/null +++ b/server/tests/Unit/Support/ReviewEligibilityTest.php @@ -0,0 +1,289 @@ +connection('mysql')->getSchemaBuilder(); + foreach (['reviews', 'orders', 'entities', 'products', 'stores', 'contacts', 'personal_access_tokens'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('reviews', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->integer('rating')->nullable(); + $table->text('content')->nullable(); + $table->boolean('rejected')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'company_uuid' => 'company_uuid', 'name' => 'Bloom']); + $connection->table('products')->insert(['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid']); + $connection->table('contacts')->insert(['uuid' => ELIGIBILITY_CUSTOMER_UUID, 'public_id' => 'contact_eligible', 'user_uuid' => 'user_uuid', 'type' => 'customer']); + $connection->table('orders')->insert([ + // Completed orders for the store, newest last so ordering is exercised. + ['uuid' => 'store_order_old', 'public_id' => 'order_old', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_old', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(5)], + ['uuid' => 'store_order_new', 'public_id' => 'order_new', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_new', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + // Not eligible: still in progress, another store, another customer. + ['uuid' => 'store_order_active', 'public_id' => 'order_active', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'dispatched', 'payload_uuid' => 'payload_active', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ['uuid' => 'other_store_order', 'public_id' => 'order_other_store', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_other']), 'created_at' => now()], + ['uuid' => 'other_customer_order', 'public_id' => 'order_other_customer', 'customer_uuid' => 'someone_else', 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ]); + // Only the newest store order contains the product. + $connection->table('entities')->insert([ + ['uuid' => 'entity_new', 'payload_uuid' => 'payload_new', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_active', 'payload_uuid' => 'payload_active', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_orphan', 'payload_uuid' => null, 'internal_id' => 'product_abcdefgh'], + ]); +} + +function eligibilityCustomer(): Contact +{ + return Contact::where('uuid', ELIGIBILITY_CUSTOMER_UUID)->first(); +} + +function bindEligibilityRequest(?string $customerToken = null, array $query = []): Request +{ + $request = Request::create('/reviews/eligibility', 'GET', $query); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + $request->setLaravelSession(new Illuminate\Session\Store('review-eligibility', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => 'store_key', 'storefront_store' => 'store_uuid', 'storefront_network' => null]); + + return $request; +} + +function issueEligibilityToken(): string +{ + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->insert([ + 'name' => ELIGIBILITY_CUSTOMER_UUID, + 'token' => hash('sha256', 'eligibility-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 'eligibility-secret'; +} + +test('review eligibility requires a signed-in customer and a store or product subject', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + + $signedOut = ReviewEligibility::check(null, $store); + $unsupported = ReviewEligibility::check(eligibilityCustomer(), new stdClass()); + + expect($signedOut->allowed)->toBeFalse() + ->and($signedOut->toArray())->toBe([ + 'can_review' => false, + 'reason' => 'sign_in_required', + 'message' => 'Sign in to write a review.', + 'order' => null, + 'review' => null, + ]) + ->and($unsupported->reason)->toBe('unsupported_subject') + ->and($unsupported->message())->toBe('Only stores and products can be reviewed.') + ->and(ReviewEligibility::completedOrdersFor(eligibilityCustomer(), new stdClass()))->toBeNull(); +}); + +test('store reviews use the newest completed order from that store that has not been reviewed', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $first = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_new', 'rating' => 5, 'content' => 'Great']); + $second = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_old', 'rating' => 4, 'content' => 'Good']); + $done = ReviewEligibility::check($customer, $store); + + expect($first->allowed)->toBeTrue() + ->and($first->reason)->toBeNull() + ->and($first->message())->toBeNull() + ->and($first->order->uuid)->toBe('store_order_new') + ->and($second->order->uuid)->toBe('store_order_old') + ->and($done->allowed)->toBeFalse() + ->and($done->reason)->toBe('already_reviewed') + ->and($done->order->public_id)->toBe('order_new') + ->and($done->review->order_uuid)->toBe('store_order_new') + ->and($done->toArray()['order'])->toBe('order_new'); +}); + +test('product reviews need a completed order containing the product and can target one order', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $product_ = ReviewEligibility::check($customer, $product); + $byPublic = ReviewEligibility::check($customer, $store, 'order_old'); + $byUuid = ReviewEligibility::check($customer, $store, 'store_order_old'); + $notMine = ReviewEligibility::check($customer, $store, 'order_other_customer'); + $missing = ReviewEligibility::check($customer, $product, 'order_old'); + + expect($product_->allowed)->toBeTrue() + ->and($product_->order->uuid)->toBe('store_order_new') + ->and($byPublic->order->uuid)->toBe('store_order_old') + ->and($byUuid->order->uuid)->toBe('store_order_old') + ->and($notMine->reason)->toBe('no_completed_order') + ->and($missing->allowed)->toBeFalse() + ->and($missing->message())->toBe('You can review this after an order with it is completed.'); +}); + +test('review eligibility endpoint validates the subject and reports the signed-in customer state', function () { + createReviewEligibilitySchema(); + $controller = new ReviewController(); + + bindEligibilityRequest(); + $missing = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_missing']); + $invalid = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_abcdefgh']); + $signedOut = $controller->eligibility(request()); + $token = issueEligibilityToken(); + bindEligibilityRequest($token, ['subject' => 'store_abcdefgh']); + $eligible = $controller->eligibility(request()); + bindEligibilityRequest($token, ['subject' => 'product_abcdefgh', 'order' => 'order_old']); + $wrongOrder = $controller->eligibility(request()); + + expect($missing->getData(true))->toBe(['error' => 'A subject is required.']) + ->and($invalid->getData(true))->toBe(['error' => 'Invalid subject for review']) + ->and($signedOut->getData(true)['reason'])->toBe('sign_in_required') + ->and($eligible->getData(true))->toBe([ + 'can_review' => true, + 'reason' => null, + 'message' => null, + 'order' => 'order_new', + 'review' => null, + ]) + ->and($wrongOrder->getData(true)['reason'])->toBe('no_completed_order'); +}); + +test('creating a review without a completed order is refused with the reason', function () { + createReviewEligibilitySchema(); + Model::getConnectionResolver()->connection('mysql')->table('orders')->update(['status' => 'canceled']); + bindEligibilityRequest(issueEligibilityToken()); + + $response = (new ReviewController())->create(CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 5, + 'content' => 'Never received it', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'error' => 'You can review this after an order with it is completed.', + 'reason' => 'no_completed_order', + ]) + ->and(Review::query()->count())->toBe(0); +}); + +test('review resources mark the viewer own verified product reviews and keep internal ids internal', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $mine = new Review(['customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'subject_uuid' => 'product_uuid', 'rating' => 4, 'content' => 'Nice']); + $mine->setRelation('subject', $product)->setRelation('customer', null)->setRelation('photos', collect()); + $other = new Review(['customer_uuid' => null, 'order_uuid' => null, 'rating' => 2, 'content' => 'Meh']); + $other->setRelation('subject', null)->setRelation('customer', null)->setRelation('photos', collect()); + + bindEligibilityRequest(issueEligibilityToken()); + $publicMine = (new ReviewResource($mine))->toArray(request()); + $publicOther = (new ReviewResource($other))->toArray(request()); + // The viewer is resolved once per request. + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->delete(); + $cached = ReviewResource::viewerCustomerUuid(); + + expect($publicMine['subject_id'])->toBe('product_abcdefgh') + ->and($publicMine['subject_type'])->toBe('product') + ->and($publicMine['verified'])->toBeFalse() + ->and($publicMine['is_mine'])->toBeTrue() + ->and($publicOther['subject_id'])->toBeNull() + ->and($publicOther['is_mine'])->toBeFalse() + ->and($cached)->toBe(ELIGIBILITY_CUSTOMER_UUID); +}); + +test('reviews belong to the completed order they were written for', function () { + $relation = (new Review())->order(); + + expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\BelongsTo::class) + ->and($relation->getRelated())->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class) + ->and($relation->getForeignKeyName())->toBe('order_uuid') + ->and($relation->getOwnerKeyName())->toBe('uuid'); +});