diff --git a/key-value-versions-app/.env.example b/key-value-versions-app/.env.example index 7243a2e..43943b6 100644 --- a/key-value-versions-app/.env.example +++ b/key-value-versions-app/.env.example @@ -65,4 +65,4 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" PASSPORT_PASSWORD_CLIENT_ID=01989503-1ce8-735f-a487-0729ef4f1d1b -PASSPORT_PASSWORD_SECRET=WqkuICsupCfBL1xLt71TfBSaaLVlaiTd0iMSudpu +PASSPORT_PASSWORD_CLIENT_SECRET=WqkuICsupCfBL1xLt71TfBSaaLVlaiTd0iMSudpu diff --git a/key-value-versions-app/app/Http/Controllers/Api/V1/AuthController.php b/key-value-versions-app/app/Http/Controllers/Api/V1/AuthController.php index 25fca16..7506569 100644 --- a/key-value-versions-app/app/Http/Controllers/Api/V1/AuthController.php +++ b/key-value-versions-app/app/Http/Controllers/Api/V1/AuthController.php @@ -8,7 +8,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Http; use Illuminate\Validation\Rules; class AuthController extends Controller @@ -31,56 +30,6 @@ public function register(Request $request): JsonResponse return response()->json(['message' => 'User registered successfully'], 201); } - public function login(Request $request): JsonResponse - { - $request->validate([ - 'email' => ['required', 'string', 'email'], - 'password' => ['required', 'string'], - ]); - - if (!Auth::attempt($request->only('email', 'password'))) { - return response()->json(['message' => 'Invalid credentials'], 401); - } - - $response = Http::asForm()->post(env('APP_URL') . '/oauth/token', [ - 'grant_type' => 'password', - 'client_id' => env('PASSPORT_PASSWORD_CLIENT_ID'), - 'client_secret' => env('PASSPORT_PASSWORD_SECRET'), - 'username' => $request->email, - 'password' => $request->password, - 'scope' => '*', - ]); - - return response()->json([ - 'user' => Auth::user(), - 'token' => $response->json(), - ]); - } - - public function me(): JsonResponse - { - $user = auth()->user(); - - return response()->json(['user' => $user]); - } - - public function refreshToken(Request $request): JsonResponse - { - $formData = $request->validate([ - 'refresh_token' => ['required', 'string'], - ]); - - $response = Http::asForm()->post(env('APP_URL') . '/oauth/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $formData->refresh_token, - 'client_id' => env('PASSPORT_PASSWORD_CLIENT_ID'), - 'client_secret' => env('PASSPORT_PASSWORD_SECRET'), - 'scope' => '*', - ]); - - return response()->json(['token' => $response->json()]); - } - public function logout(): JsonResponse { Auth::user()->tokens()->delete(); diff --git a/key-value-versions-app/app/Http/Controllers/Api/V1/KeyValueVersionController.php b/key-value-versions-app/app/Http/Controllers/Api/V1/KeyValueVersionController.php new file mode 100644 index 0000000..a7f8ed5 --- /dev/null +++ b/key-value-versions-app/app/Http/Controllers/Api/V1/KeyValueVersionController.php @@ -0,0 +1,128 @@ +repository = $repository; + } + + /** + * Store a newly created resource in storage. + * This method handles POST /api/v1/keys + */ + public function store(Request $request) + { + $request->validate([ + 'key' => 'required|string|max:255', + 'value' => 'required', + ]); + + $hash = hash('sha256', $request->value); + + $current = $this->repository->getLatestByKey($request->key); + + if ($current && $current->value_hash === $hash) { + return response()->json( + [ + 'key' => $current->key, + 'value' => json_decode($current->value, true) ?? $current->value, + 'createdAt' => $current->created_at->timestamp, + ] + ); + } + + $new = $this->repository->create( + [ + 'key' => $request->key, + 'value' => $request->value, + 'value_hash' => $hash, + 'created_at' => Carbon::now('UTC'), + ] + ); + + return response()->json( + [ + 'key' => $new->key, + 'value' => json_decode($new->value, true) ?? $new->value, + 'createdAt' => $new->created_at->timestamp, + ], + 201, + ); + } + + /** + * Display a listing of all currently stored keys with their latest values. + * This method handles GET /api/v1/keys + */ + public function index() + { + $all = $this->repository->getAllLatestKeys(); + + $formatted = $all->map(function ($latest) { + return [ + 'key' => $latest->key, + 'value' => json_decode($latest->value, true) ?? $latest->value, + 'createdAt' => $latest->created_at->timestamp, + ]; + }); + + return response()->json($formatted); + } + + /** + * Display the specified resource (latest value or value at timestamp). + * This method handles GET /v1/keys/{key} and /v1/keys/{key}?timestamp={unix_timestamp} + */ + public function show(Request $request, string $key) + { + if (! $request->has('timestamp') || ! is_numeric($request->query('timestamp'))) { + $latest = $this->repository->getLatestByKey($key); + } else { + $timestamp = Carbon::createFromTimestamp((int) $request->query('timestamp'), 'UTC'); + $latest = $this->repository->getLatestByKeyAndTimestamp($key, $timestamp); + } + + if ($latest === null) { + return response()->json(['error' => 'Key not found or no version at specified timestamp'], 404); + } + + return response()->json([ + 'key' => $latest->key, + 'value' => json_decode($latest->value, true) ?? $latest->value, + 'createdAt' => $latest->created_at->timestamp, + ]); + } + + /** + * Display all values (versions) for a key. + * This method handles GET /v1/keys/{key}/history + */ + public function history(string $key) + { + $all = $this->repository->getAllByKey($key); + + if ($all->isEmpty()) { + return response()->json(['error' => 'Key not found'], 404); + } + + $formatted = $all->map(function ($version) { + return [ + 'key' => $version->key, + 'value' => json_decode($version->value, true) ?? $version->value, + 'createdAt' => $version->created_at->timestamp, + ]; + }); + + return response()->json($formatted); + } +} diff --git a/key-value-versions-app/app/Models/KeyValueVersion.php b/key-value-versions-app/app/Models/KeyValueVersion.php index 4cb53d2..060fffd 100644 --- a/key-value-versions-app/app/Models/KeyValueVersion.php +++ b/key-value-versions-app/app/Models/KeyValueVersion.php @@ -2,12 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class KeyValueVersion extends Model { protected $table = 'key_value_versions'; + use HasFactory; + protected $fillable = [ 'key', 'value', diff --git a/key-value-versions-app/app/Repositories/KeyValueVersionRepository.php b/key-value-versions-app/app/Repositories/KeyValueVersionRepository.php new file mode 100644 index 0000000..aad6f0f --- /dev/null +++ b/key-value-versions-app/app/Repositories/KeyValueVersionRepository.php @@ -0,0 +1,70 @@ +model = $model; + } + + public function getAllLatestKeys() + { + return $this->model + ->whereIn('id', function ($query) { + $query->select(DB::raw('MAX(id)')) + ->from('key_value_versions') + ->groupBy('key'); + })->get(); + } + + public function create(array $data): KeyValueVersion + { + return $this->model->create($data); + } + + public function getLatestByKey(string $key): ?KeyValueVersion + { + return $this->model + ->where('key', $key) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + } + + public function getAllByKey(string $key) + { + return $this->model + ->where('key', $key) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get(); + } + + public function getLatestByKeyAndTimestamp(string $key, $timestamp): ?KeyValueVersion + { + return $this->model + ->where('key', $key) + ->where('created_at', '<=', $timestamp) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + } +} diff --git a/key-value-versions-app/bootstrap/app.php b/key-value-versions-app/bootstrap/app.php index 7b3394a..2f7d821 100644 --- a/key-value-versions-app/bootstrap/app.php +++ b/key-value-versions-app/bootstrap/app.php @@ -9,9 +9,9 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__ . '/../routes/web.php', - api: __DIR__ . '/../routes/api.php', - commands: __DIR__ . '/../routes/console.php', + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', health: '/up', apiPrefix: 'api/v1', ) diff --git a/key-value-versions-app/config/passport.php b/key-value-versions-app/config/passport.php index 647a1da..a1dc1e1 100644 --- a/key-value-versions-app/config/passport.php +++ b/key-value-versions-app/config/passport.php @@ -30,6 +30,19 @@ 'public_key' => env('PASSPORT_PUBLIC_KEY'), + /* + |-------------------------------------------------------------------------- + | Client ID and Secret + |-------------------------------------------------------------------------- + | + | Client ID and Secret + | + */ + + 'client_id' => env('PASSPORT_PASSWORD_CLIENT_ID'), + + 'client_secret' => env('PASSPORT_PASSWORD_CLIENT_SECRET'), + /* |-------------------------------------------------------------------------- | Passport Database Connection diff --git a/key-value-versions-app/database/factories/KeyValueVersionFactory.php b/key-value-versions-app/database/factories/KeyValueVersionFactory.php new file mode 100644 index 0000000..5d977a3 --- /dev/null +++ b/key-value-versions-app/database/factories/KeyValueVersionFactory.php @@ -0,0 +1,23 @@ + $this->faker->word]); + + return [ + 'key' => $this->faker->word, + 'value' => $value, + 'value_hash' => hash('sha256', $value), + 'created_at' => now(), + ]; + } +} diff --git a/key-value-versions-app/routes/api.php b/key-value-versions-app/routes/api.php index f9d8592..086bda3 100644 --- a/key-value-versions-app/routes/api.php +++ b/key-value-versions-app/routes/api.php @@ -1,19 +1,21 @@ ['auth:api']], function () { + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/user', function (Request $request) { return $request->user(); }); - Route::post('/logout', [AuthController::class, 'logout']); + Route::post('/keys', [KeyValueVersionController::class, 'store']); + Route::get('/keys', [KeyValueVersionController::class, 'index']); + Route::get('/keys/{key}', [KeyValueVersionController::class, 'show']); + Route::get('/keys/{key}/history', [KeyValueVersionController::class, 'history']); }); diff --git a/key-value-versions-app/tests/Feature/Auth/AuthenticationTest.php b/key-value-versions-app/tests/Feature/Auth/AuthenticationTest.php index 7d08f50..a272b9d 100644 --- a/key-value-versions-app/tests/Feature/Auth/AuthenticationTest.php +++ b/key-value-versions-app/tests/Feature/Auth/AuthenticationTest.php @@ -38,4 +38,4 @@ $this->assertGuest(); $response->assertRedirect('/'); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/Auth/EmailVerificationTest.php b/key-value-versions-app/tests/Feature/Auth/EmailVerificationTest.php index e303f41..f282dff 100644 --- a/key-value-versions-app/tests/Feature/Auth/EmailVerificationTest.php +++ b/key-value-versions-app/tests/Feature/Auth/EmailVerificationTest.php @@ -43,4 +43,4 @@ $this->actingAs($user)->get($verificationUrl); expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/Auth/PasswordConfirmationTest.php b/key-value-versions-app/tests/Feature/Auth/PasswordConfirmationTest.php index d5894f1..8a42902 100644 --- a/key-value-versions-app/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/key-value-versions-app/tests/Feature/Auth/PasswordConfirmationTest.php @@ -29,4 +29,4 @@ ]); $response->assertSessionHasErrors(); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/Auth/PasswordResetTest.php b/key-value-versions-app/tests/Feature/Auth/PasswordResetTest.php index 4d243b4..0504276 100644 --- a/key-value-versions-app/tests/Feature/Auth/PasswordResetTest.php +++ b/key-value-versions-app/tests/Feature/Auth/PasswordResetTest.php @@ -57,4 +57,4 @@ return true; }); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/Auth/RegistrationTest.php b/key-value-versions-app/tests/Feature/Auth/RegistrationTest.php index f1d859c..352ca78 100644 --- a/key-value-versions-app/tests/Feature/Auth/RegistrationTest.php +++ b/key-value-versions-app/tests/Feature/Auth/RegistrationTest.php @@ -16,4 +16,4 @@ $this->assertAuthenticated(); $response->assertRedirect(route('dashboard', absolute: false)); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/DashboardTest.php b/key-value-versions-app/tests/Feature/DashboardTest.php index 15804df..0b95c79 100644 --- a/key-value-versions-app/tests/Feature/DashboardTest.php +++ b/key-value-versions-app/tests/Feature/DashboardTest.php @@ -10,4 +10,4 @@ $this->actingAs($user = User::factory()->create()); $this->get('/dashboard')->assertOk(); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/KeyValueVersionControllerTest.php b/key-value-versions-app/tests/Feature/KeyValueVersionControllerTest.php new file mode 100644 index 0000000..db1b27a --- /dev/null +++ b/key-value-versions-app/tests/Feature/KeyValueVersionControllerTest.php @@ -0,0 +1,140 @@ +baseUrl = '/api/v1/keys'; + + Passport::actingAs( + User::factory()->create(), + ['*'] // scopes + ); +}); + +it('stores a new key value version', function () { + $payload = [ + 'key' => 'foo', + 'value' => json_encode(['bar' => 'baz']), + ]; + + $response = $this->postJson($this->baseUrl, $payload); + + $response->assertStatus(201) + ->assertJsonFragment([ + 'key' => 'foo', + 'value' => ['bar' => 'baz'], + ]); + + $this->assertDatabaseHas('key_value_versions', [ + 'key' => 'foo', + ]); +}); + +it('returns existing version if hash matches', function () { + $value = json_encode(['bar' => 'baz']); + $existing = KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'value' => $value, + 'value_hash' => hash('sha256', $value), + ]); + + $response = $this->postJson($this->baseUrl, [ + 'key' => 'foo', + 'value' => $value, + ]); + + $response->assertStatus(200) + ->assertJsonFragment([ + 'key' => 'foo', + 'value' => ['bar' => 'baz'], + 'createdAt' => $existing->created_at->timestamp, + ]); + + $this->assertDatabaseCount('key_value_versions', 1); +}); + +it('lists all latest keys', function () { + KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now()->subDay(), + ]); + KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now(), + ]); + KeyValueVersion::factory()->create([ + 'key' => 'bar', + ]); + + $response = $this->getJson($this->baseUrl); + + $response->assertOk(); + $data = $response->json(); + expect($data)->toHaveCount(2); +}); + +it('shows the latest value for a key', function () { + $latest = KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now(), + ]); + + $response = $this->getJson("{$this->baseUrl}/foo"); + + $response->assertOk() + ->assertJsonFragment([ + 'key' => 'foo', + 'createdAt' => $latest->created_at->timestamp, + ]); +}); + +it('shows the value for a key at a specific timestamp', function () { + $old = KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => Carbon::parse('2023-01-01'), + ]); + KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => Carbon::parse('2023-02-01'), + ]); + + $timestamp = Carbon::parse('2023-01-15')->timestamp; + + $response = $this->getJson("{$this->baseUrl}/foo?timestamp={$timestamp}"); + + $response->assertOk() + ->assertJsonFragment([ + 'key' => 'foo', + 'createdAt' => $old->created_at->timestamp, + ]); +}); + +it('returns 404 when key not found', function () { + $response = $this->getJson("{$this->baseUrl}/notfound"); + + $response->assertStatus(404); +}); + +it('shows the history for a key', function () { + KeyValueVersion::factory()->count(3)->create([ + 'key' => 'foo', + ]); + + $response = $this->getJson("{$this->baseUrl}/foo/history"); + + $response->assertOk(); + $data = $response->json(); + expect($data)->toHaveCount(3); +}); + +it('returns 404 when requesting history for non-existent key', function () { + $response = $this->getJson("{$this->baseUrl}/notfound/history"); + + $response->assertStatus(404); +}); diff --git a/key-value-versions-app/tests/Feature/Repositories/KeyValueVersionRepositoryTest.php b/key-value-versions-app/tests/Feature/Repositories/KeyValueVersionRepositoryTest.php new file mode 100644 index 0000000..b964def --- /dev/null +++ b/key-value-versions-app/tests/Feature/Repositories/KeyValueVersionRepositoryTest.php @@ -0,0 +1,92 @@ +repository = app(KeyValueVersionRepository::class); +}); + +it('can create a record', function () { + $data = [ + 'key' => 'foo', + 'value' => json_encode(['bar' => 'baz']), + 'value_hash' => hash('sha256', json_encode(['bar' => 'baz'])), + 'created_at' => now(), + ]; + + $record = $this->repository->create($data); + + expect($record)->toBeInstanceOf(KeyValueVersion::class); + $this->assertDatabaseHas('key_value_versions', ['key' => 'foo']); +}); + +it('can get latest by key', function () { + KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now()->subDay(), + ]); + + $latest = KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now(), + ]); + + $result = $this->repository->getLatestByKey('foo'); + + expect($result->id)->toBe($latest->id); +}); + +it('can get all latest keys', function () { + KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now()->subDay(), + ]); + $latestFoo = KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => now(), + ]); + + KeyValueVersion::factory()->create([ + 'key' => 'bar', + 'created_at' => now(), + ]); + + $results = $this->repository->getAllLatestKeys(); + + expect($results)->toHaveCount(2) + ->and($results->contains('id', $latestFoo->id))->toBeTrue(); +}); + +it('can get all by key', function () { + $records = KeyValueVersion::factory()->count(3)->create([ + 'key' => 'foo', + ]); + + $results = $this->repository->getAllByKey('foo'); + + expect($results)->toHaveCount(3) + ->and($results->first()->id)->toBe($records->last()->id); +}); + +it('can get latest by key and timestamp', function () { + $oldRecord = KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => Carbon::parse('2023-01-01'), + ]); + + KeyValueVersion::factory()->create([ + 'key' => 'foo', + 'created_at' => Carbon::parse('2023-02-01'), + ]); + + $timestamp = Carbon::parse('2023-01-15'); + + $result = $this->repository->getLatestByKeyAndTimestamp('foo', $timestamp); + + expect($result->id)->toBe($oldRecord->id); +}); diff --git a/key-value-versions-app/tests/Feature/Settings/PasswordUpdateTest.php b/key-value-versions-app/tests/Feature/Settings/PasswordUpdateTest.php index f4566fc..16533df 100644 --- a/key-value-versions-app/tests/Feature/Settings/PasswordUpdateTest.php +++ b/key-value-versions-app/tests/Feature/Settings/PasswordUpdateTest.php @@ -37,4 +37,4 @@ $response ->assertSessionHasErrors('current_password') ->assertRedirect('/settings/password'); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Feature/Settings/ProfileUpdateTest.php b/key-value-versions-app/tests/Feature/Settings/ProfileUpdateTest.php index cdf17c2..2194465 100644 --- a/key-value-versions-app/tests/Feature/Settings/ProfileUpdateTest.php +++ b/key-value-versions-app/tests/Feature/Settings/ProfileUpdateTest.php @@ -82,4 +82,4 @@ ->assertRedirect('/settings/profile'); expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file +}); diff --git a/key-value-versions-app/tests/Unit/ExampleTest.php b/key-value-versions-app/tests/Unit/ExampleTest.php index 27f3f87..44a4f33 100644 --- a/key-value-versions-app/tests/Unit/ExampleTest.php +++ b/key-value-versions-app/tests/Unit/ExampleTest.php @@ -2,4 +2,4 @@ test('that true is true', function () { expect(true)->toBeTrue(); -}); \ No newline at end of file +});