Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions app/Http/Controllers/Tweets/IndexController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
}
31 changes: 20 additions & 11 deletions app/Services/TweetService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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']);
});
Expand Down
5 changes: 4 additions & 1 deletion pint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
54 changes: 54 additions & 0 deletions resources/js/components/TweetsAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -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<string, number>;
};

interface Props {
analytics: TweetAnalytics;
}

function TweetAnalytics({ analytics }: Props) {
return (
<Card className="mb-6">
<CardHeader>
<CardTitle>Tweets Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<ul className="space-y-2">
<li>Total Tweets: {analytics.totalTweets}</li>
<li>Max Days Between Tweets: {analytics.maxDaysBetweenTweets}</li>
<li>Most Tweets in a Day: {analytics.mostNumberOfTweetsPerDay}</li>
<li>Most Popular Hashtag: {analytics.mostPopularHashtag}</li>
</ul>
</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={Object.entries(analytics.numberOfTweetsPerDay).map(([date, count]) => ({
date,
count
}))}
>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#4f46e5" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
);
}

export default TweetAnalytics;
37 changes: 31 additions & 6 deletions resources/js/pages/TweetsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -99,18 +104,38 @@ 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 (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Twitter Feed</CardTitle>
</CardHeader>
<CardContent>
<form className="flex gap-4 mb-6">
<form className="flex gap-4 mb-6" onSubmit={(e) => e.preventDefault()}>
<Input
type="text"
value={userName}
onChange={(e) => 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"
/>
Expand Down
97 changes: 71 additions & 26 deletions tests/Unit/Services/TweetServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
],
];
}
}