diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php new file mode 100644 index 0000000..8032ab9 --- /dev/null +++ b/app/Http/Controllers/Api/PostController.php @@ -0,0 +1,63 @@ +filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('site_id')) { + $query->where('site_id', $request->integer('site_id')); + } + + if ($request->filled('social_account_id')) { + $query->where('social_account_id', $request->integer('social_account_id')); + } + + if ($request->boolean('due')) { + $query->where('status', 'scheduled') + ->where('scheduled_at', '<=', now()); + } + + return PostResource::collection($query->orderByDesc('created_at')->get()); + } + + public function show(Post $post) + { + return new PostResource($post); + } + + public function store(StorePostRequest $request) + { + $post = Post::create($request->validated())->fresh(); + + return (new PostResource($post))->response()->setStatusCode(201); + } + + public function update(UpdatePostRequest $request, Post $post) + { + $post->update($request->validated()); + + return new PostResource($post); + } + + public function destroy(Post $post) + { + $post->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Requests/StorePostRequest.php b/app/Http/Requests/StorePostRequest.php new file mode 100644 index 0000000..0ee9d9f --- /dev/null +++ b/app/Http/Requests/StorePostRequest.php @@ -0,0 +1,27 @@ + ['required', 'integer', 'exists:sites,id'], + 'social_account_id' => ['nullable', 'integer', 'exists:social_accounts,id'], + 'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)], + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], + 'scheduled_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdatePostRequest.php b/app/Http/Requests/UpdatePostRequest.php new file mode 100644 index 0000000..76c24bf --- /dev/null +++ b/app/Http/Requests/UpdatePostRequest.php @@ -0,0 +1,27 @@ + ['sometimes', 'required', 'integer', 'exists:sites,id'], + 'social_account_id' => ['nullable', 'integer', 'exists:social_accounts,id'], + 'type' => ['sometimes', 'required', 'string', 'in:' . implode(',', Post::TYPES)], + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['sometimes', 'required', 'string'], + 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], + 'scheduled_at' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php new file mode 100644 index 0000000..b10ebd0 --- /dev/null +++ b/app/Http/Resources/PostResource.php @@ -0,0 +1,25 @@ + $this->id, + 'site_id' => $this->site_id, + 'social_account_id' => $this->social_account_id, + 'type' => $this->type, + 'title' => $this->title, + 'body' => $this->body, + 'status' => $this->status, + 'scheduled_at' => $this->scheduled_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 805fcca..949850b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ names('api.posts'); diff --git a/tests/Feature/Api/PostsApiTest.php b/tests/Feature/Api/PostsApiTest.php new file mode 100644 index 0000000..0328c9a --- /dev/null +++ b/tests/Feature/Api/PostsApiTest.php @@ -0,0 +1,136 @@ +count(3)->create(); + + $response = $this->getJson('/api/posts'); + + $response->assertOk()->assertJsonCount(3, 'data'); +}); + +test('it filters posts by status', function () { + Post::factory()->create(['status' => 'draft']); + Post::factory()->create(['status' => 'published']); + + $response = $this->getJson('/api/posts?status=published'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.status', 'published'); +}); + +test('it filters posts by site_id', function () { + $site = Site::factory()->create(); + Post::factory()->create(['site_id' => $site->id]); + Post::factory()->create(); + + $response = $this->getJson("/api/posts?site_id={$site->id}"); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it filters posts by social_account_id', function () { + $account = SocialAccount::factory()->create(); + Post::factory()->create(['social_account_id' => $account->id]); + Post::factory()->create(['social_account_id' => null]); + + $response = $this->getJson("/api/posts?social_account_id={$account->id}"); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it filters posts that are due for publishing', function () { + Post::factory()->create(['status' => 'scheduled', 'scheduled_at' => now()->subHour()]); + Post::factory()->create(['status' => 'scheduled', 'scheduled_at' => now()->addDay()]); + Post::factory()->create(['status' => 'draft', 'scheduled_at' => now()->subHour()]); + + $response = $this->getJson('/api/posts?due=1'); + + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +test('it shows a single post', function () { + $post = Post::factory()->create(['title' => 'Solo Post']); + + $response = $this->getJson("/api/posts/{$post->id}"); + + $response->assertOk()->assertJsonPath('data.title', 'Solo Post'); +}); + +test('it returns 404 for a missing post', function () { + $this->getJson('/api/posts/999')->assertNotFound(); +}); + +test('it creates a post with required fields only', function () { + $site = Site::factory()->create(); + + $response = $this->postJson('/api/posts', [ + 'site_id' => $site->id, + 'type' => 'blog', + 'body' => 'New content', + ]); + + $response->assertCreated() + ->assertJsonPath('data.site_id', $site->id) + ->assertJsonPath('data.type', 'blog') + ->assertJsonPath('data.body', 'New content') + ->assertJsonPath('data.status', 'draft'); + + expect(Post::where('body', 'New content')->exists())->toBeTrue(); +}); + +test('it rejects creating a post without a valid site_id', function () { + $response = $this->postJson('/api/posts', [ + 'type' => 'blog', + 'body' => 'New content', + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['site_id']); +}); + +test('it rejects creating a post without a valid type', function () { + $site = Site::factory()->create(); + + $response = $this->postJson('/api/posts', [ + 'site_id' => $site->id, + 'type' => 'not-a-real-type', + 'body' => 'New content', + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['type']); +}); + +test('it rejects creating a post without a body', function () { + $site = Site::factory()->create(); + + $response = $this->postJson('/api/posts', [ + 'site_id' => $site->id, + 'type' => 'blog', + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors(['body']); +}); + +test('it updates a post status', function () { + $post = Post::factory()->create(['status' => 'scheduled']); + + $response = $this->patchJson("/api/posts/{$post->id}", [ + 'status' => 'published', + ]); + + $response->assertOk()->assertJsonPath('data.status', 'published'); + expect($post->fresh()->status)->toBe('published'); +}); + +test('it deletes a post', function () { + $post = Post::factory()->create(); + + $response = $this->deleteJson("/api/posts/{$post->id}"); + + $response->assertNoContent(); + expect(Post::find($post->id))->toBeNull(); +});