diff --git a/.cursor/rules/reports.mdc b/.cursor/rules/reports.mdc index dac07a6..0208538 100644 --- a/.cursor/rules/reports.mdc +++ b/.cursor/rules/reports.mdc @@ -25,6 +25,20 @@ Public creates go through `StoreScammerReportRequest` or `StoreOrganizationRepor A linked scammer or organization does not count as the required contact or payment method. That check reads only the top-level `contacts` and `payment_methods`. +## Report against an existing record + +`POST /api/public/scammers/{id}/reports` and `POST /api/public/organizations/{id}/reports` file a report against an active scammer or organization. They do not create or rename that record. A missing, inactive, or soft-deleted id is not found. + +These creates go through `StoreAttachedReportRequest`, then `StoreReportForScammerCommand` or `StoreReportForOrganizationCommand`, then `ReportUsecase`. The request trims `title` and `email`, and title-cases each product name. + +- `title`: required string, max 50. +- `description`: required string. +- `email`: required, `email:rfc`, max 255. +- `products`: required array, at least one item. Each item is a string, max 75. +- `proofs`: optional array of temporary public paths. The same temporary-file check and copy used by a new report applies. Scammer proofs are stored under `reports/scammers/proofs`. Organization proofs are stored under `reports/organizations/proofs`. + +These bodies do not accept contacts, payment methods, or a profile picture. An invalid id is `400`. + ## Images Upload the file before the report body. diff --git a/.cursor/rules/scammers-and-organizations.mdc b/.cursor/rules/scammers-and-organizations.mdc index 66b408c..de3af93 100644 --- a/.cursor/rules/scammers-and-organizations.mdc +++ b/.cursor/rules/scammers-and-organizations.mdc @@ -14,6 +14,7 @@ They link through the `scammers_organizations` pivot. A scammer can belong to ma - A scammer report uses `StoreScammerReportRequest`. `scammer.name` is required. `organizations` is optional. - An organization report uses `StoreOrganizationReportRequest`. `organization.name` is required. `scammers` is optional. - The report still needs at least one contact or payment method on that primary subject. A linked organization or scammer does not satisfy that check. See `reports.mdc`. +- A report filed against an existing scammer or organization by id does not send contacts or payment methods. See `reports.mdc`. ## Attaching the other side later diff --git a/app/Application/Report/Commands/StoreReportForOrganizationCommand.php b/app/Application/Report/Commands/StoreReportForOrganizationCommand.php new file mode 100644 index 0000000..d8c7f26 --- /dev/null +++ b/app/Application/Report/Commands/StoreReportForOrganizationCommand.php @@ -0,0 +1,72 @@ + $proofs + * @param list $productNames + */ + public function __construct( + public int $organizationId, + public string $title, + public string $description, + public string $email, + public array $proofs, + public array $productNames, + ) {} + + /** + * @param array $input + */ + public static function fromValidated(int $organizationId, array $input): self + { + return new self( + organizationId: $organizationId, + title: (string) ($input['title'] ?? ''), + description: is_string($input['description'] ?? null) ? $input['description'] : '', + email: (string) ($input['email'] ?? ''), + proofs: self::proofsFrom($input['proofs'] ?? []), + productNames: self::productNamesFrom($input['products'] ?? []), + ); + } + + /** + * @return list + */ + private static function proofsFrom(mixed $proofs): array + { + if (! is_array($proofs)) { + return []; + } + + $paths = []; + + foreach ($proofs as $url) { + $paths[] = is_string($url) ? $url : ''; + } + + return $paths; + } + + /** + * @return list + */ + private static function productNamesFrom(mixed $products): array + { + if (! is_array($products)) { + return []; + } + + $names = []; + + foreach ($products as $name) { + if (is_string($name) && $name !== '') { + $names[] = $name; + } + } + + return $names; + } +} diff --git a/app/Application/Report/Commands/StoreReportForScammerCommand.php b/app/Application/Report/Commands/StoreReportForScammerCommand.php new file mode 100644 index 0000000..96d4cd5 --- /dev/null +++ b/app/Application/Report/Commands/StoreReportForScammerCommand.php @@ -0,0 +1,72 @@ + $proofs + * @param list $productNames + */ + public function __construct( + public int $scammerId, + public string $title, + public string $description, + public string $email, + public array $proofs, + public array $productNames, + ) {} + + /** + * @param array $input + */ + public static function fromValidated(int $scammerId, array $input): self + { + return new self( + scammerId: $scammerId, + title: (string) ($input['title'] ?? ''), + description: is_string($input['description'] ?? null) ? $input['description'] : '', + email: (string) ($input['email'] ?? ''), + proofs: self::proofsFrom($input['proofs'] ?? []), + productNames: self::productNamesFrom($input['products'] ?? []), + ); + } + + /** + * @return list + */ + private static function proofsFrom(mixed $proofs): array + { + if (! is_array($proofs)) { + return []; + } + + $paths = []; + + foreach ($proofs as $url) { + $paths[] = is_string($url) ? $url : ''; + } + + return $paths; + } + + /** + * @return list + */ + private static function productNamesFrom(mixed $products): array + { + if (! is_array($products)) { + return []; + } + + $names = []; + + foreach ($products as $name) { + if (is_string($name) && $name !== '') { + $names[] = $name; + } + } + + return $names; + } +} diff --git a/app/Application/Report/Usecases/ReportUsecase.php b/app/Application/Report/Usecases/ReportUsecase.php index 6fd956a..6fd2489 100644 --- a/app/Application/Report/Usecases/ReportUsecase.php +++ b/app/Application/Report/Usecases/ReportUsecase.php @@ -9,6 +9,8 @@ use App\Application\Report\Commands\ScammerReportOrganizationInput; use App\Application\Report\Commands\SearchReportsCommand; use App\Application\Report\Commands\StoreOrganizationReportCommand; +use App\Application\Report\Commands\StoreReportForOrganizationCommand; +use App\Application\Report\Commands\StoreReportForScammerCommand; use App\Application\Report\Commands\StoreScammerReportCommand; use App\Application\Report\Commands\StoreTemporaryProofsCommand; use App\Domain\Contact\Entities\ContactEntity; @@ -30,6 +32,7 @@ use App\Repositories\Scammer\ScammerRepositoryInterface; use App\Repositories\Search\SearchRepositoryInterface; use App\Repositories\User\UserRepositoryInterface; +use Closure; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; use InvalidArgumentException; @@ -282,6 +285,128 @@ public function storeScammer(StoreScammerReportCommand $command): array } } + /** + * @return array{id: int, scammer_id: int, product_ids: list, report_proof_ids: list}|null + */ + public function storeForScammer(StoreReportForScammerCommand $command): ?array + { + $scammer = $this->scammers->findScammerById($command->scammerId); + + if ($scammer === null) { + return null; + } + + $created = $this->createAttachedReport( + $command->title, + $command->description, + $command->email, + $command->proofs, + $command->productNames, + self::SCAMMER_PROOF_DIRECTORY, + fn (Report $report) => $this->reports->attachToScammer($scammer, $report), + ); + + return [ + 'id' => $created['id'], + 'scammer_id' => $scammer->id, + 'product_ids' => $created['product_ids'], + 'report_proof_ids' => $created['report_proof_ids'], + ]; + } + + /** + * @return array{id: int, organization_id: int, product_ids: list, report_proof_ids: list}|null + */ + public function storeForOrganization(StoreReportForOrganizationCommand $command): ?array + { + $organization = $this->organizations->findOrganizationById($command->organizationId); + + if ($organization === null) { + return null; + } + + $created = $this->createAttachedReport( + $command->title, + $command->description, + $command->email, + $command->proofs, + $command->productNames, + self::ORGANIZATION_PROOF_DIRECTORY, + fn (Report $report) => $this->reports->attachToOrganization($organization, $report), + ); + + return [ + 'id' => $created['id'], + 'organization_id' => $organization->id, + 'product_ids' => $created['product_ids'], + 'report_proof_ids' => $created['report_proof_ids'], + ]; + } + + /** + * @param list $proofs + * @param list $productNames + * @param Closure(Report): void $attach + * @return array{id: int, product_ids: list, report_proof_ids: list} + */ + private function createAttachedReport( + string $title, + string $description, + string $email, + array $proofs, + array $productNames, + string $proofDirectory, + Closure $attach, + ): array { + $copied = []; + + try { + $proofPaths = []; + + foreach ($proofs as $index => $url) { + $path = $this->copyTemporary( + $url, + TemporaryImageStorageInterface::PROOF_DIRECTORY, + $proofDirectory, + 'proofs.'.$index, + ); + $copied[] = $path; + $proofPaths[] = $path; + } + + return DB::transaction(function () use ( + $title, + $description, + $email, + $productNames, + $proofPaths, + $attach, + ): array { + $user = $this->users->findByEmail($email) ?? $this->users->createReporter($email); + $report = $this->reports->create($title, $description, $user->id); + $attach($report); + $productIds = $this->attachProducts($report, $productNames); + $proofIds = []; + + foreach ($proofPaths as $path) { + $proofIds[] = $this->reports->addProof($report, $path); + } + + return [ + 'id' => $report->id, + 'product_ids' => $productIds, + 'report_proof_ids' => $proofIds, + ]; + }); + } catch (Throwable $exception) { + foreach ($copied as $path) { + $this->images->deletePublished($path); + } + + throw $exception; + } + } + /** * @param list $names * @return list diff --git a/app/Application/Report/Usecases/ReportUsecaseInterface.php b/app/Application/Report/Usecases/ReportUsecaseInterface.php index 1e1065a..017b96e 100644 --- a/app/Application/Report/Usecases/ReportUsecaseInterface.php +++ b/app/Application/Report/Usecases/ReportUsecaseInterface.php @@ -4,6 +4,8 @@ use App\Application\Report\Commands\SearchReportsCommand; use App\Application\Report\Commands\StoreOrganizationReportCommand; +use App\Application\Report\Commands\StoreReportForOrganizationCommand; +use App\Application\Report\Commands\StoreReportForScammerCommand; use App\Application\Report\Commands\StoreScammerReportCommand; use App\Application\Report\Commands\StoreTemporaryProofsCommand; use App\Domain\Search\ValueObjects\CardSearchResult; @@ -26,4 +28,14 @@ public function storeOrganization(StoreOrganizationReportCommand $command): arra * @return array{id: int, scammer_id: int, contact_ids: list, payment_method_ids: list, product_ids: list, organization_ids: list, report_proof_ids: list} */ public function storeScammer(StoreScammerReportCommand $command): array; + + /** + * @return array{id: int, scammer_id: int, product_ids: list, report_proof_ids: list}|null + */ + public function storeForScammer(StoreReportForScammerCommand $command): ?array; + + /** + * @return array{id: int, organization_id: int, product_ids: list, report_proof_ids: list}|null + */ + public function storeForOrganization(StoreReportForOrganizationCommand $command): ?array; } diff --git a/app/Http/Controllers/Public/ReportController.php b/app/Http/Controllers/Public/ReportController.php index 40099df..0706b52 100644 --- a/app/Http/Controllers/Public/ReportController.php +++ b/app/Http/Controllers/Public/ReportController.php @@ -4,11 +4,14 @@ use App\Application\Report\Commands\SearchReportsCommand; use App\Application\Report\Commands\StoreOrganizationReportCommand; +use App\Application\Report\Commands\StoreReportForOrganizationCommand; +use App\Application\Report\Commands\StoreReportForScammerCommand; use App\Application\Report\Commands\StoreScammerReportCommand; use App\Application\Report\Commands\StoreTemporaryProofsCommand; use App\Application\Report\Usecases\ReportUsecaseInterface; use App\Domain\Scammer\ValueObjects\Clue; use App\Http\Controllers\Controller; +use App\Http\Requests\Public\StoreAttachedReportRequest; use App\Http\Requests\Public\StoreOrganizationReportRequest; use App\Http\Requests\Public\StoreProofRequest; use App\Http\Requests\Public\StoreScammerReportRequest; @@ -60,4 +63,38 @@ public function storeScammer(StoreScammerReportRequest $request): JsonResponse { return response()->json($this->reports->storeScammer(StoreScammerReportCommand::fromValidated($request->validated())), 201); } + + public function storeForScammer(StoreAttachedReportRequest $request, string $id): JsonResponse + { + if (filter_var($id, FILTER_VALIDATE_INT) === false) { + return response()->json(['message' => 'Invalid scammer ID'], 400); + } + + $created = $this->reports->storeForScammer( + StoreReportForScammerCommand::fromValidated((int) $id, $request->validated()), + ); + + if ($created === null) { + return response()->json(['message' => 'Scammer not found'], 404); + } + + return response()->json($created, 201); + } + + public function storeForOrganization(StoreAttachedReportRequest $request, string $id): JsonResponse + { + if (filter_var($id, FILTER_VALIDATE_INT) === false) { + return response()->json(['message' => 'Invalid organization ID'], 400); + } + + $created = $this->reports->storeForOrganization( + StoreReportForOrganizationCommand::fromValidated((int) $id, $request->validated()), + ); + + if ($created === null) { + return response()->json(['message' => 'Organization not found'], 404); + } + + return response()->json($created, 201); + } } diff --git a/app/Http/Middleware/LogIpActivity.php b/app/Http/Middleware/LogIpActivity.php index 5569f78..9df92aa 100644 --- a/app/Http/Middleware/LogIpActivity.php +++ b/app/Http/Middleware/LogIpActivity.php @@ -90,7 +90,10 @@ private function activity(string $action): ?array PublicOrganizationController::class.'@createContact' => $this->describe('organization.contact.created', 'organization', 'route'), PublicOrganizationController::class.'@createPaymentMethod' => $this->describe('organization.payment_method.created', 'organization', 'route'), PublicOrganizationController::class.'@createScammer' => $this->describe('organization.scammer.created', 'organization', 'route'), - ReportController::class.'@storeScammer', ReportController::class.'@storeOrganization' => $this->describe('report.created', 'report', 'response'), + ReportController::class.'@storeScammer', + ReportController::class.'@storeOrganization', + ReportController::class.'@storeForScammer', + ReportController::class.'@storeForOrganization' => $this->describe('report.created', 'report', 'response'), AdminScammerController::class.'@store' => $this->describe('scammer.created', 'scammer', 'response'), AdminScammerController::class.'@update' => $this->describe('scammer.updated', 'scammer', 'route'), AdminScammerController::class.'@destroy' => $this->describe('scammer.deleted', 'scammer', 'route'), diff --git a/app/Http/Requests/Public/StoreAttachedReportRequest.php b/app/Http/Requests/Public/StoreAttachedReportRequest.php new file mode 100644 index 0000000..34b8f4c --- /dev/null +++ b/app/Http/Requests/Public/StoreAttachedReportRequest.php @@ -0,0 +1,52 @@ +input('title'))) { + $merge['title'] = trim($this->input('title')); + } + + if (is_string($this->input('email'))) { + $merge['email'] = trim($this->input('email')); + } + + $products = $this->input('products'); + + if (is_array($products)) { + $merge['products'] = array_map( + fn (mixed $name): mixed => is_string($name) + ? mb_convert_case(trim($name), MB_CASE_TITLE, 'UTF-8') + : $name, + $products, + ); + } + + $this->merge($merge); + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:50'], + 'description' => ['required', 'string'], + 'email' => ['required', 'email:rfc', 'max:255'], + 'proofs' => ['sometimes', 'array'], + 'proofs.*' => ['required', 'string'], + 'products' => ['required', 'array', 'min:1'], + 'products.*' => ['required', 'string', 'max:75'], + ]; + } +} diff --git a/openapi.yaml b/openapi.yaml index 29f7361..19d5f9a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -37,6 +37,12 @@ info: The service stores a copy under `reports/scammers/profiles` or `reports/organizations/profiles` and returns the public URL of the stored file. `POST /public/media/profiles` uploads a temporary profile picture for reports and profile updates. + `POST /public/scammers/{id}/reports` files a report against an active scammer. + `POST /public/organizations/{id}/reports` files a report against an active organization. + Each body sends a title, description, email, and at least one product. Proofs are optional + public-disk paths from `POST /public/reports/media/proofs`. + The report belongs to the user for that email. A user is created when the email is new, + and an existing user is reused. The scammer or organization is left unchanged. Dates use `YYYY-MM-DD`. The SEO profile index returns `updatedAt` as a UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) and is cached for 7 days. @@ -409,6 +415,68 @@ paths: message: Scammer reports not found "429": $ref: "#/components/responses/RateLimited" + post: + tags: [Scammers] + operationId: createScammerReport + summary: File a report against an existing scammer + description: | + Creates a report and attaches it to an active scammer. The scammer is not created or changed. + A missing, inactive, or soft-deleted scammer is not found. + `title`, `description`, `email`, and `products` are required. `proofs` is optional. + Each product name is title-cased before it is created or reused: the first letter of each word is capitalized and the rest is lowercase. + A product whose name already exists is reused, including a difference in letter case. The stored spelling is kept. + `proofs` are public-disk paths previously returned by `POST /public/reports/media/proofs`, + such as `/storage/tmp/reports/proofs/file.jpg`, and the files must still be present. + The report belongs to the user for that email and is active. + A user is created when the email is new, and an existing user is reused. + parameters: + - $ref: "#/components/parameters/ScammerId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAttachedReportRequest" + example: + email: reporter@example.com + title: Called me about a prize + description: They asked for a transfer to release the prize. + proofs: + - /storage/tmp/reports/proofs/8f3c1a.jpg + products: + - Crypto + responses: + "201": + description: The report was created and attached to the scammer. + content: + application/json: + schema: + $ref: "#/components/schemas/ScammerAttachedReportCreated" + example: + id: 1 + scammer_id: 2 + product_ids: [8] + report_proof_ids: [6] + "400": + description: The scammer ID is not an integer. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + message: Invalid scammer ID + "404": + description: No active scammer exists for this ID. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + message: Scammer not found + "422": + $ref: "#/components/responses/ValidationFailed" + "429": + $ref: "#/components/responses/RateLimited" /public/scammers/{id}/organizations: get: tags: [Scammers] @@ -892,6 +960,68 @@ paths: message: Organization reports not found "429": $ref: "#/components/responses/RateLimited" + post: + tags: [Organizations] + operationId: createOrganizationReport + summary: File a report against an existing organization + description: | + Creates a report and attaches it to an active organization. The organization is not created or changed. + A missing, inactive, or soft-deleted organization is not found. + `title`, `description`, `email`, and `products` are required. `proofs` is optional. + Each product name is title-cased before it is created or reused: the first letter of each word is capitalized and the rest is lowercase. + A product whose name already exists is reused, including a difference in letter case. The stored spelling is kept. + `proofs` are public-disk paths previously returned by `POST /public/reports/media/proofs`, + such as `/storage/tmp/reports/proofs/file.jpg`, and the files must still be present. + The report belongs to the user for that email and is active. + A user is created when the email is new, and an existing user is reused. + parameters: + - $ref: "#/components/parameters/OrganizationId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAttachedReportRequest" + example: + email: reporter@example.com + title: Fake store took my money + description: They never shipped the order. + proofs: + - /storage/tmp/reports/proofs/8f3c1a.jpg + products: + - Crypto + responses: + "201": + description: The report was created and attached to the organization. + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationAttachedReportCreated" + example: + id: 1 + organization_id: 2 + product_ids: [8] + report_proof_ids: [6] + "400": + description: The organization ID is not an integer. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + message: Invalid organization ID + "404": + description: No active organization exists for this ID. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + message: Organization not found + "422": + $ref: "#/components/responses/ValidationFailed" + "429": + $ref: "#/components/responses/RateLimited" /public/organizations/{id}/scammers: get: tags: [Organizations] @@ -1812,6 +1942,82 @@ components: type: string maxLength: 255 description: The type is inferred from this value. + CreateAttachedReportRequest: + type: object + required: [email, title, description, products] + properties: + email: + type: string + format: email + maxLength: 255 + example: reporter@example.com + title: + type: string + maxLength: 50 + example: Called me about a prize + description: + type: string + example: They asked for a transfer to release the prize. + proofs: + type: array + items: + type: string + description: Public-disk path of a file under `tmp/reports/proofs`. + example: /storage/tmp/reports/proofs/8f3c1a.jpg + products: + type: array + minItems: 1 + items: + type: string + maxLength: 75 + example: Crypto + description: Product names to create or reuse and attach to the report. Each name is title-cased before matching, and an existing match keeps its stored spelling. + ScammerAttachedReportCreated: + type: object + required: [id, scammer_id, product_ids, report_proof_ids] + properties: + id: + type: integer + minimum: 1 + description: Created report ID. + example: 1 + scammer_id: + type: integer + minimum: 1 + example: 2 + product_ids: + type: array + items: + type: integer + minimum: 1 + report_proof_ids: + type: array + items: + type: integer + minimum: 1 + OrganizationAttachedReportCreated: + type: object + required: [id, organization_id, product_ids, report_proof_ids] + properties: + id: + type: integer + minimum: 1 + description: Created report ID. + example: 1 + organization_id: + type: integer + minimum: 1 + example: 2 + product_ids: + type: array + items: + type: integer + minimum: 1 + report_proof_ids: + type: array + items: + type: integer + minimum: 1 ScammerReportCreated: type: object required: [id, scammer_id, contact_ids, payment_method_ids, product_ids, organization_ids, report_proof_ids] diff --git a/routes/api.public.php b/routes/api.public.php index 18ca0c6..f553c2b 100644 --- a/routes/api.public.php +++ b/routes/api.public.php @@ -27,6 +27,8 @@ Route::post('organizations/{id}/payment-methods', [OrganizationController::class, 'createPaymentMethod']) ->middleware(LogIpActivity::class); Route::get('organizations/{id}/reports', [OrganizationController::class, 'reports']); + Route::post('organizations/{id}/reports', [ReportController::class, 'storeForOrganization']) + ->middleware(LogIpActivity::class); Route::get('organizations/{id}/scammers', [OrganizationController::class, 'scammers']); Route::post('organizations/{id}/scammers', [OrganizationController::class, 'createScammer']) ->middleware(LogIpActivity::class); @@ -44,6 +46,8 @@ Route::post('scammers/{id}/payment-methods', [ScammerController::class, 'createPaymentMethod']) ->middleware(LogIpActivity::class); Route::get('scammers/{id}/reports', [ScammerController::class, 'reports']); + Route::post('scammers/{id}/reports', [ReportController::class, 'storeForScammer']) + ->middleware(LogIpActivity::class); Route::get('scammers/{id}/organizations', [ScammerController::class, 'organizations']); Route::post('scammers/{id}/organizations', [ScammerController::class, 'createOrganization']) ->middleware(LogIpActivity::class); diff --git a/tests/Feature/StoreAttachedReportTest.php b/tests/Feature/StoreAttachedReportTest.php new file mode 100644 index 0000000..a2b8783 --- /dev/null +++ b/tests/Feature/StoreAttachedReportTest.php @@ -0,0 +1,285 @@ +create([ + 'name' => 'Juan Perez', + 'profile_picture_path' => null, + ]); + $proofs = [ + $this->publishTemporary(TemporaryImageStorageInterface::PROOF_DIRECTORY, 'one.jpg', 'one'), + $this->publishTemporary(TemporaryImageStorageInterface::PROOF_DIRECTORY, 'two.png', 'two'), + ]; + + $response = $this->fromIp()->postJson("/api/public/scammers/{$scammer->id}/reports", [ + 'title' => ' Called me about a prize ', + 'description' => 'They asked for a transfer to release the prize.', + 'email' => ' reporter@example.com ', + 'products' => ['crypto wallet', 'Banking'], + 'proofs' => $proofs, + ]); + + $response->assertCreated(); + $response->assertJsonPath('scammer_id', $scammer->id); + + $report = Report::query()->first(); + $user = User::query()->where('email', 'reporter@example.com')->first(); + $products = Product::query()->orderBy('name')->get(); + + $this->assertNotNull($report); + $this->assertNotNull($user); + $this->assertSame($report->id, $response->json('id')); + $this->assertSame($user->id, $report->user_id); + $this->assertSame('reporter', $user->role); + $this->assertTrue($report->is_active); + $this->assertSame('Called me about a prize', $report->title); + $this->assertSame('They asked for a transfer to release the prize.', $report->description); + $this->assertTrue($scammer->reports()->whereKey($report->id)->exists()); + $this->assertSame(1, Scammer::query()->count()); + $this->assertSame(0, Organization::query()->count()); + $this->assertSame('Juan Perez', $scammer->fresh()->name); + $this->assertNull($scammer->fresh()->profile_picture_path); + $this->assertSame(['Banking', 'Crypto Wallet'], $products->pluck('name')->all()); + $this->assertSame($products->pluck('id')->sort()->values()->all(), collect($response->json('product_ids'))->sort()->values()->all()); + $this->assertEqualsCanonicalizing($products->modelKeys(), $report->products()->pluck('products.id')->all()); + + $storedProofs = ReportProof::query()->where('report_id', $report->id)->orderBy('id')->get(); + $this->assertCount(2, $storedProofs); + $this->assertSame($storedProofs->pluck('id')->all(), $response->json('report_proof_ids')); + $this->assertSame(['one', 'two'], [ + Storage::disk('public')->get($storedProofs[0]->path), + Storage::disk('public')->get($storedProofs[1]->path), + ]); + $this->assertStringStartsWith('reports/scammers/proofs/', $storedProofs[0]->path); + $this->assertStringStartsWith('reports/scammers/proofs/', $storedProofs[1]->path); + $this->assertTrue(Storage::disk('public')->exists(TemporaryImageStorageInterface::PROOF_DIRECTORY.'/one.jpg')); + $this->assertTrue(Storage::disk('public')->exists(TemporaryImageStorageInterface::PROOF_DIRECTORY.'/two.png')); + + $this->assertSame(1, IpActivity::query()->count()); + $this->assertDatabaseHas('ip_activities', [ + 'action' => 'report.created', + 'subject_type' => 'report', + 'subject_id' => $report->id, + 'ip_address' => self::IP, + 'actor_id' => null, + ]); + } + + public function test_files_a_report_against_an_existing_organization(): void + { + $organization = Organization::factory()->create(['name' => 'Tienda Falsa']); + $proof = $this->publishTemporary(TemporaryImageStorageInterface::PROOF_DIRECTORY, 'receipt.jpg', 'receipt'); + + $response = $this->fromIp()->postJson("/api/public/organizations/{$organization->id}/reports", [ + 'title' => 'Fake store took my money', + 'description' => 'They never shipped the order.', + 'email' => 'reporter@example.com', + 'products' => ['Crypto'], + 'proofs' => [$proof], + ]); + + $response->assertCreated(); + $response->assertJsonPath('organization_id', $organization->id); + + $report = Report::query()->first(); + $this->assertNotNull($report); + $this->assertSame($report->id, $response->json('id')); + $this->assertTrue($organization->reports()->whereKey($report->id)->exists()); + $this->assertSame(1, Organization::query()->count()); + $this->assertSame(0, Scammer::query()->count()); + $this->assertSame('Tienda Falsa', $organization->fresh()->name); + + $storedProof = ReportProof::query()->where('report_id', $report->id)->first(); + $this->assertNotNull($storedProof); + $this->assertSame([$storedProof->id], $response->json('report_proof_ids')); + $this->assertStringStartsWith('reports/organizations/proofs/', $storedProof->path); + $this->assertSame('receipt', Storage::disk('public')->get($storedProof->path)); + $this->assertTrue(Storage::disk('public')->exists(TemporaryImageStorageInterface::PROOF_DIRECTORY.'/receipt.jpg')); + + $this->assertDatabaseHas('ip_activities', [ + 'action' => 'report.created', + 'subject_type' => 'report', + 'subject_id' => $report->id, + 'ip_address' => self::IP, + ]); + } + + public function test_reuses_an_existing_reporter_and_product_when_proofs_are_omitted(): void + { + $user = User::factory()->create(['email' => 'reporter@example.com', 'role' => 'reporter']); + $product = Product::factory()->create(['name' => 'Crypto']); + $scammer = Scammer::factory()->create(); + $organization = Organization::factory()->create(); + + $scammerResponse = $this->postJson("/api/public/scammers/{$scammer->id}/reports", [ + 'title' => 'Second call', + 'description' => 'They called again.', + 'email' => 'reporter@example.com', + 'products' => ['crypto', 'CRYPTO'], + ]); + $organizationResponse = $this->postJson("/api/public/organizations/{$organization->id}/reports", [ + 'title' => 'Store follow-up', + 'description' => 'Still no shipment.', + 'email' => 'reporter@example.com', + 'products' => ['Crypto'], + 'proofs' => [], + ]); + + $scammerResponse->assertCreated(); + $organizationResponse->assertCreated(); + $scammerResponse->assertJsonPath('product_ids', [$product->id]); + $scammerResponse->assertJsonPath('report_proof_ids', []); + $organizationResponse->assertJsonPath('product_ids', [$product->id]); + $organizationResponse->assertJsonPath('report_proof_ids', []); + $this->assertSame(1, User::query()->count()); + $this->assertSame(1, Product::query()->count()); + $this->assertSame( + [$user->id, $user->id], + Report::query()->orderBy('id')->pluck('user_id')->all(), + ); + $this->assertSame(0, ReportProof::query()->count()); + } + + public function test_rejects_invalid_bodies_and_unpublished_proofs(): void + { + $scammer = Scammer::factory()->create(); + $validProof = $this->publishTemporary(TemporaryImageStorageInterface::PROOF_DIRECTORY, 'one.jpg', 'one'); + + $missing = $this->postJson("/api/public/scammers/{$scammer->id}/reports", []); + $missing->assertUnprocessable(); + $missing->assertJsonPath('error.code', 'validation_failed'); + foreach (['title', 'description', 'email', 'products'] as $field) { + $this->assertArrayHasKey($field, $missing->json('error.details')); + } + + $organization = Organization::factory()->create(); + $emptyProducts = $this->postJson("/api/public/organizations/{$organization->id}/reports", $this->payload([ + 'products' => [], + 'email' => 'not-an-email', + 'title' => str_repeat('a', 51), + ])); + $emptyProducts->assertUnprocessable(); + $details = $emptyProducts->json('error.details'); + $this->assertArrayHasKey('products', $details); + $this->assertArrayHasKey('email', $details); + $this->assertArrayHasKey('title', $details); + + $badProof = $this->postJson("/api/public/scammers/{$scammer->id}/reports", $this->payload([ + 'proofs' => [$validProof, '/storage/tmp/reports/proofs/missing.jpg'], + ])); + $badProof->assertUnprocessable(); + $this->assertArrayHasKey('proofs.1', $badProof->json('error.details')); + $this->assertSame(0, Report::query()->count()); + $this->assertSame(0, User::query()->count()); + $this->assertSame([], Storage::disk('public')->allFiles('reports/scammers/proofs')); + $this->assertTrue(Storage::disk('public')->exists(TemporaryImageStorageInterface::PROOF_DIRECTORY.'/one.jpg')); + $this->assertSame(0, IpActivity::query()->count()); + } + + public function test_rejects_a_missing_inactive_or_invalid_scammer(): void + { + $inactive = Scammer::factory()->inactive()->create(); + $deleted = Scammer::factory()->create(); + $deleted->delete(); + + $this->postJson('/api/public/scammers/nope/reports', $this->payload()) + ->assertBadRequest() + ->assertExactJson(['message' => 'Invalid scammer ID']); + + $this->postJson('/api/public/scammers/999999/reports', $this->payload()) + ->assertNotFound() + ->assertExactJson(['message' => 'Scammer not found']); + + $this->postJson("/api/public/scammers/{$inactive->id}/reports", $this->payload()) + ->assertNotFound() + ->assertExactJson(['message' => 'Scammer not found']); + + $this->postJson("/api/public/scammers/{$deleted->id}/reports", $this->payload()) + ->assertNotFound() + ->assertExactJson(['message' => 'Scammer not found']); + + $this->assertSame(0, Report::query()->count()); + $this->assertSame(0, User::query()->count()); + $this->assertSame(0, IpActivity::query()->count()); + } + + public function test_rejects_a_missing_inactive_or_invalid_organization(): void + { + $inactive = Organization::factory()->inactive()->create(); + $deleted = Organization::factory()->create(); + $deleted->delete(); + + $this->postJson('/api/public/organizations/nope/reports', $this->payload()) + ->assertBadRequest() + ->assertExactJson(['message' => 'Invalid organization ID']); + + $this->postJson('/api/public/organizations/999999/reports', $this->payload()) + ->assertNotFound() + ->assertExactJson(['message' => 'Organization not found']); + + $this->postJson("/api/public/organizations/{$inactive->id}/reports", $this->payload()) + ->assertNotFound() + ->assertExactJson(['message' => 'Organization not found']); + + $this->postJson("/api/public/organizations/{$deleted->id}/reports", $this->payload()) + ->assertNotFound() + ->assertExactJson(['message' => 'Organization not found']); + + $this->assertSame(0, Report::query()->count()); + $this->assertSame(0, IpActivity::query()->count()); + } + + /** + * @param array $overrides + * @return array + */ + private function payload(array $overrides = []): array + { + return [ + 'title' => 'Called me about a prize', + 'description' => 'They asked for a transfer.', + 'email' => 'reporter@example.com', + 'products' => ['Crypto'], + ...$overrides, + ]; + } + + private function fromIp(): static + { + return $this->withServerVariables(['REMOTE_ADDR' => self::IP]); + } + + private function publishTemporary(string $directory, string $filename, string $contents): string + { + $relative = $directory.'/'.$filename; + Storage::disk('public')->put($relative, $contents); + + $urlPath = parse_url((string) config('filesystems.disks.public.url'), PHP_URL_PATH); + + return rtrim(is_string($urlPath) ? $urlPath : '', '/').'/'.$relative; + } +} diff --git a/tests/Unit/OpenApiDocumentTest.php b/tests/Unit/OpenApiDocumentTest.php index 2ca6db6..a5b5deb 100644 --- a/tests/Unit/OpenApiDocumentTest.php +++ b/tests/Unit/OpenApiDocumentTest.php @@ -72,12 +72,14 @@ public function test_project_spec_bundles_shared_schemas(): void $this->assertSame('listScammerPaymentMethods', $document['paths']['/public/scammers/{id}/payment-methods']['get']['operationId']); $this->assertSame('createScammerPaymentMethod', $document['paths']['/public/scammers/{id}/payment-methods']['post']['operationId']); $this->assertSame('listScammerReports', $document['paths']['/public/scammers/{id}/reports']['get']['operationId']); + $this->assertSame('createScammerReport', $document['paths']['/public/scammers/{id}/reports']['post']['operationId']); $this->assertSame('listScammerOrganizations', $document['paths']['/public/scammers/{id}/organizations']['get']['operationId']); $this->assertSame('createScammerOrganization', $document['paths']['/public/scammers/{id}/organizations']['post']['operationId']); $this->assertSame('listOrganizationPaymentMethods', $document['paths']['/public/organizations/{id}/payment-methods']['get']['operationId']); $this->assertSame('createOrganizationPaymentMethod', $document['paths']['/public/organizations/{id}/payment-methods']['post']['operationId']); $this->assertSame('createOrganizationContact', $document['paths']['/public/organizations/{id}/contacts']['post']['operationId']); $this->assertSame('listOrganizationReports', $document['paths']['/public/organizations/{id}/reports']['get']['operationId']); + $this->assertSame('createOrganizationReport', $document['paths']['/public/organizations/{id}/reports']['post']['operationId']); $this->assertSame('listOrganizationScammers', $document['paths']['/public/organizations/{id}/scammers']['get']['operationId']); $this->assertSame('createOrganizationScammer', $document['paths']['/public/organizations/{id}/scammers']['post']['operationId']); $this->assertSame('suggestOrganizationNames', $document['paths']['/public/organizations/suggest']['get']['operationId']);