diff --git a/app/Http/Controllers/Tweets/IndexController.php b/app/Http/Controllers/Tweets/IndexController.php index 8e839e9..8a3b603 100644 --- a/app/Http/Controllers/Tweets/IndexController.php +++ b/app/Http/Controllers/Tweets/IndexController.php @@ -10,13 +10,20 @@ class IndexController extends Controller { public function __invoke(GetAllTweetsRequest $request, TweetService $tweetService) { - $tweets = $tweetService->getTweets($request->username, $request->page, $request->per_page); - $analytics = $tweetService->getAnalytics($tweets['all_tweets']); + try { + $tweets = $tweetService->getTweets($request->username, $request->page, $request->per_page); + $analytics = $tweetService->getAnalytics($tweets['all_tweets']); + + return response()->json([ + 'tweets' => $tweets['tweets'], + 'pagination' => $tweets['pagination'], + 'analytics' => $analytics, + ]); + } catch (\Throwable $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], $exception->getCode()); + } - return response()->json([ - 'tweets' => $tweets['tweets'], - 'pagination' => $tweets['pagination'], - 'analytics' => $analytics, - ]); } } diff --git a/app/Services/TweetService.php b/app/Services/TweetService.php index e07c324..6654299 100644 --- a/app/Services/TweetService.php +++ b/app/Services/TweetService.php @@ -4,6 +4,7 @@ use App\Transformers\TweetTransformer; use Carbon\Carbon; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -20,18 +21,27 @@ public function __construct( private const CACHE_TTL = 1800; // 30 minutes in seconds - public function getTweetsFromApi(string $userName): array + public function getTweetsFromApi(string $userName) { - $response = Http::withToken(self::API_TOKEN) - ->get(self::API_URL, [ - 'userName' => $userName, - ]); - - if ($response->failed()) { - throw new \Exception('Failed to fetch tweets: '.$response->status()); + try { + $response = Http::withToken(self::API_TOKEN) + ->get(self::API_URL, [ + 'userNamde' => $userName, + ]); + + $response->throw(); + + return $response->json(); + } catch (RequestException $e) { + // TODO: Add logging for easier debugging + // Log::error('Failed to fetch tweets', [ + // 'userName' => $userName, + // 'status' => $e->response->status(), + // 'error' => $e->getMessage(), + // ]); + + throw $e; } - - return $response->json(); } public function getTweets(string $userName, ?int $page = 1, ?int $perPage = 10): array @@ -44,7 +54,6 @@ public function getTweets(string $userName, ?int $page = 1, ?int $perPage = 10): $this->getTweetsFromApi($userName) ); - // TODO: check with them if tweets need to be sorted chronologically usort($data, function ($a, $b) { return Carbon::parse($a['createdAt']) <=> Carbon::parse($b['createdAt']); }); diff --git a/pint.json b/pint.json index 8330ce8..e8bb4f5 100644 --- a/pint.json +++ b/pint.json @@ -11,6 +11,9 @@ "default": "align_single_space_minimal" }, "no_unused_imports": true, - "not_operator_with_successor_space": false + "not_operator_with_successor_space": false, + "ordered_imports": { + "sort_algorithm": "alpha" + } } } diff --git a/resources/js/components/TweetsAnalytics.tsx b/resources/js/components/TweetsAnalytics.tsx new file mode 100644 index 0000000..3087e8f --- /dev/null +++ b/resources/js/components/TweetsAnalytics.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; + +type TweetAnalytics = { + totalTweets: number; + maxDaysBetweenTweets: number; + mostNumberOfTweetsPerDay: number; + mostPopularHashtag: string; + numberOfTweetsPerDay: Record; +}; + +interface Props { + analytics: TweetAnalytics; +} + +function TweetAnalytics({ analytics }: Props) { + return ( + + + Tweets Statistics + + +
+
+
    +
  • Total Tweets: {analytics.totalTweets}
  • +
  • Max Days Between Tweets: {analytics.maxDaysBetweenTweets}
  • +
  • Most Tweets in a Day: {analytics.mostNumberOfTweetsPerDay}
  • +
  • Most Popular Hashtag: {analytics.mostPopularHashtag}
  • +
+
+
+ + ({ + date, + count + }))} + > + + + + + + +
+
+
+
+ ); +} + +export default TweetAnalytics; diff --git a/resources/js/pages/TweetsFeed.tsx b/resources/js/pages/TweetsFeed.tsx index 63bb18e..c0b82a5 100644 --- a/resources/js/pages/TweetsFeed.tsx +++ b/resources/js/pages/TweetsFeed.tsx @@ -58,15 +58,20 @@ const TweetApp = () => { const response = await fetch(`/api/tweets?username=${userName}&page=${page}&per_page=${perPage}`); if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch tweets'); + + handleError(response, errorData); } const data: TweetResponse = await response.json(); setTweets(data.tweets); setPagination(data.pagination); setCurrentPage(page); setAnalytics(data.analytics); - } catch (err) { - setError('Failed to fetch tweets'); + } catch (err: any) { + setError(err instanceof Error ? err.message : 'An unexpected error occurred'); + setTweets([]); + setPagination(null); + setAnalytics(null); + setCurrentPage(1); } finally { setLoading(false); } @@ -99,6 +104,25 @@ const TweetApp = () => { } }, [currentPage]); + const handleError = (response: Response, errorData: any) => { + if (response.status === 400) { + if (errorData.message?.toLowerCase().includes('username')) { + setError('Please enter a valid username.'); + } else { + throw new Error(errorData.message || 'Failed to fetch tweets'); + } + throw new Error('Please enter a valid username'); + } else if (response.status === 404) { + throw new Error('We are unable to find any data, please check the username and try again'); + } else if (response.status === 429) { + throw new Error('Too many requests, please try again in a few minutes'); + } else if (response.status >= 500) { + throw new Error('An error occurred, please try again in a few minutes and contact support if the issue persists'); + } else { + throw new Error(errorData.message || 'Failed to fetch tweets'); + } + } + return (
@@ -106,11 +130,12 @@ const TweetApp = () => { Twitter Feed -
+ e.preventDefault()}> setUserName(e.target.value)} + defaultValue={userName} + onChange={(e) => error && setError('')} + onBlur={(e) => setUserName(e.target.value)} placeholder="Enter username (e.g. joe_smith)" className="flex-grow" /> diff --git a/tests/Unit/Services/TweetServiceTest.php b/tests/Unit/Services/TweetServiceTest.php index 1af2b87..2c4a720 100644 --- a/tests/Unit/Services/TweetServiceTest.php +++ b/tests/Unit/Services/TweetServiceTest.php @@ -4,11 +4,11 @@ use App\Services\TweetService; use App\Transformers\TweetTransformer; -use Exception; use Illuminate\Http\Client\Request; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; -use Throwable; class TweetServiceTest extends TestCase { @@ -88,30 +88,6 @@ public function test_in_case_of_consecutive_calls_cached_data_should_be_returned Http::assertSentCount(1); } - public function test_if_http_request_fails_it_should_throw_an_exception() - { - // Arrange - $userName = 'joe_smith'; - $tweetTransformerMock = $this->mock(TweetTransformer::class); - - // Act - Http::fake([ - 'app.codescreen.com/api/assessments/tweets*' => Http::response(new Exception('request exception'), 500), - ]); - - $tweetService = new TweetService($tweetTransformerMock); - try { - $tweetService->getTweets($userName); - } catch (Throwable $throwable) { - // Assert - $this->assertEquals('Failed to fetch tweets: 500', $throwable->getMessage()); - - return; - } - - $this->fail('Exception has not been thrown'); - } - public function test_it_should_return_paginated_data_if_there_are_too_many_tweets() { // Arrange @@ -359,4 +335,73 @@ public function test_it_should_return_an_empty_array_if_there_is_no_response() ], ], $result); } + + #[DataProvider('http_error_provider')] + public function test_get_tweets_from_api_handles_http_errors(int $status, string $error, string $exception): void + { + // Arrange + $userName = 'joe_smith'; + Http::fake([ + 'app.codescreen.com/api/assessments/tweets*' => Http::response([ + 'message' => $error, + ], $status), + ]); + + $tweetService = new TweetService(new TweetTransformer()); + + try { + // Act + $tweetService->getTweetsFromApi($userName); + } catch (RequestException $e) { + // Assert + $message = json_decode($e->response->getBody()->getContents())->message; + $this->assertEquals($error, $message); + $this->assertInstanceOf($exception, $e); + + return; + } + + $this->fail('Exception has not been thrown'); + } + + public static function http_error_provider(): array + { + return [ + 'bad request' => [ + 'status' => 400, + 'error' => 'Bad Request', + 'exception' => RequestException::class, + ], + 'unauthorized' => [ + 'status' => 401, + 'error' => 'Unauthorized', + 'exception' => RequestException::class, + ], + 'forbidden' => [ + 'status' => 403, + 'error' => 'Forbidden', + 'exception' => RequestException::class, + ], + 'not found' => [ + 'status' => 404, + 'error' => 'Not Found', + 'exception' => RequestException::class, + ], + 'method not allowed' => [ + 'status' => 405, + 'error' => 'Method Not Allowed', + 'exception' => RequestException::class, + ], + 'server error' => [ + 'status' => 500, + 'error' => 'Internal Server Error', + 'exception' => RequestException::class, + ], + 'service unavailable' => [ + 'status' => 503, + 'error' => 'Service Unavailable', + 'exception' => RequestException::class, + ], + ]; + } }