From 02bb843fb91aae894468532b684ee57219356694 Mon Sep 17 00:00:00 2001 From: Nathan Heffley Date: Sun, 8 Aug 2021 00:23:56 -0400 Subject: [PATCH] Initial commit --- .gitignore | 6 + LICENSE.md | 7 + README.md | 186 ++++++++++ composer.json | 47 +++ config/watermelon.php | 13 + docker-compose.yml | 15 + phpunit.xml.dist | 35 ++ src/Exceptions/ConflictException.php | 10 + src/SyncController.php | 20 + src/SyncService.php | 165 +++++++++ src/Traits/Watermelon.php | 20 + src/WatermelonServiceProvider.php | 40 ++ tests/Feature/ModelAuthorizationTest.php | 221 +++++++++++ tests/Feature/MultipleModelPullTest.php | 260 +++++++++++++ tests/Feature/MultipleModelPushTest.php | 339 +++++++++++++++++ tests/Feature/NoModelPullTest.php | 67 ++++ tests/Feature/NoModelPushTest.php | 46 +++ tests/Feature/SingleModelPullTest.php | 345 ++++++++++++++++++ tests/Feature/SingleModelPushTest.php | 259 +++++++++++++ tests/TestCase.php | 20 + tests/Unit/ModelTest.php | 55 +++ ...021_08_07_000000_create_projects_table.php | 34 ++ .../2021_08_07_000000_create_tasks_table.php | 35 ++ tests/models/Project.php | 17 + tests/models/Task.php | 22 ++ tests/models/TaskScoped.php | 30 ++ 26 files changed, 2314 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/watermelon.php create mode 100644 docker-compose.yml create mode 100644 phpunit.xml.dist create mode 100644 src/Exceptions/ConflictException.php create mode 100644 src/SyncController.php create mode 100644 src/SyncService.php create mode 100644 src/Traits/Watermelon.php create mode 100644 src/WatermelonServiceProvider.php create mode 100644 tests/Feature/ModelAuthorizationTest.php create mode 100644 tests/Feature/MultipleModelPullTest.php create mode 100644 tests/Feature/MultipleModelPushTest.php create mode 100644 tests/Feature/NoModelPullTest.php create mode 100644 tests/Feature/NoModelPushTest.php create mode 100644 tests/Feature/SingleModelPullTest.php create mode 100644 tests/Feature/SingleModelPushTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ModelTest.php create mode 100644 tests/database/2021_08_07_000000_create_projects_table.php create mode 100644 tests/database/2021_08_07_000000_create_tasks_table.php create mode 100644 tests/models/Project.php create mode 100644 tests/models/Task.php create mode 100644 tests/models/TaskScoped.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e5cb68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor +.env +.phpunit.result.cache +composer.phar +composer.lock +phpunit.xml diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d286eba --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2021 Nathan Heffley + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8ba61d --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# Laravel Watermelon + +This package provides a [Watermelon DB](https://nozbe.github.io/WatermelonDB/) backend sync implementation for Laravel. +Watermelon DB is a robust local database synchronization tool to help develop offline-first application. One of the +biggest hurdles is implementing the logic on your server to handle the synchronization process. + +That's where this package comes in to provide a quick synchronization route you can get up and running in minutes. + +> __This project is still in active development__ and does not support schema versions or migrations yet. Both of these +> are major parts of the Watermelon DB spec. Please expect large changes at least until those features are implemented. + +## Installation + +Before getting started you'll need to install the package and publish the config file. + +``` +composer require nathanheffley/laravel-watermelon +``` + +``` +php artisan vendor:publish --tag="watermelon-config" +``` + +## Usage + +Once you've installed the package, you need to specify which models will be available through the synchronization +endpoint. Open up the `config/watermelon.php` file and update the `models` array. The key needs to be the name of table +used locally in your application, and the value must be classname of the related model. + +You can also change the route to be something other than `/sync` by editing the config file or setting the +`WATERMELON_ROUTE` environment variable. You will have to ensure your application makes synchronization requests to +whatever route you specify. + +By default, only your global middleware will be applied to the synchronization endpoint. This means that unless you changed +the default global middleware in your Laravel project the synchronization endpoint will be unauthenticated. If you want to +have access to the currently authenticated user you will need to add the `web` middleware to the config file's +`middleware` array. If you want to restrict access to the synchronization endpoint to authenticated users only, you can +add the `auth` middleware in addition to the `web` middleware. Of course, you can add any middleware you would like as +long as it's registered in your project. + +```php + env('WATERMELON_ROUTE', '/watermelon'), + + 'middleware' => [ + 'web', + 'auth', + ], + + 'models' => [ + 'projects' => Project::class, + 'tasks' => Task::class, + ], + +]; +``` + +Once you've specified which models should be available through the synchronization endpoint, you'll need to implement +some functionality in the models to support being served as Watermelon change objects. + +You will need to add a database column to all of your models to keep track of what the Watermelon ID is, called +`watermelon_id`. The default IDs generated by Watermelon are alphanumeric strings, although you can change the type of +the column if you don't use the default IDs autogenerated by Watermelon. A unique index on the column is recommended, +and I like placing it directly after the `id` column. + +```php +string('watermelon_id')->after('id')->unique(); + }); + } + + public function down() + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropColumn('watermelon_id'); + }); + } +} +``` + +If you did not originally include the `created_at`, `updated_at`, and `deleted_at` timestamp columns, you will also need +to add those columns. Please refer to the Laravel documentation for implementing the +[timestamps](https://laravel.com/docs/8.x/eloquent#timestamps) and +[soft deleting](https://laravel.com/docs/8.x/eloquent#soft-deleting) functionality. + +The only thing you __must__ change in your model class is to use the `Watermelon` trait. + +```php +where('user_id', Auth::user()->id); + } +} +``` + +## Package Development + +If you have PHP and Composer installed on your local machine you should be able to easily run the PHPUnit test suite. + +If you prefer to run the tests within a Docker container, this project includes +[Laravel Sail](https://laravel.com/docs/8.x/sail). + +To install the dependencies: + +``` +docker run --rm \ + -u "$(id -u):$(id -g)" \ + -v $(pwd):/opt \ + -w /opt \ + laravelsail/php80-composer:latest \ + composer install --ignore-platform-reqs +``` + +To run the test suite: + +``` +sail exec laravel.test ./vendor/bin/phpunit +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a7d074f --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "nathanheffley/laravel-watermelon", + "type": "library", + "description": "Easily set up a sync endpoint to support Watermelon DB in your Laravel projects.", + "keywords": ["package", "laravel", "offline", "database", "watermelon", "watermelondb"], + "license": "MIT", + "authors": [ + { + "name": "Nathan Heffley", + "email": "nathan@nathanheffley.com" + } + ], + "require": { + "php": "^8.0", + "illuminate/database": "^8.0", + "illuminate/http": "^8.0", + "illuminate/routing": "^8.0", + "illuminate/support": "^8.0" + }, + "require-dev": { + "laravel/sail": "^1.9", + "orchestra/testbench": "^6.19", + "phpunit/phpunit": "^9.5" + }, + "autoload": { + "psr-4": { + "NathanHeffley\\LaravelWatermelon\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "NathanHeffley\\LaravelWatermelon\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "NathanHeffley\\LaravelWatermelon\\WatermelonServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/watermelon.php b/config/watermelon.php new file mode 100644 index 0000000..0f8ea29 --- /dev/null +++ b/config/watermelon.php @@ -0,0 +1,13 @@ + env('WATERMELON_ROUTE', '/sync'), + + 'middleware' => [], + + 'models' => [ + // 'tasks' => '\App\Models\Task', + ], + +]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7d59b6d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +# For more information: https://laravel.com/docs/sail +version: '3' +services: + laravel.test: + build: + context: ./vendor/laravel/sail/runtimes/8.0 + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: sail-8.0/app + environment: + WWWUSER: '${WWWUSER}' + LARAVEL_SAIL: 1 + volumes: + - '.:/var/www/html' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bf96cc8 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + + + + + ./src + + + + + ./tests/Unit + + + ./tests/Feature + + + diff --git a/src/Exceptions/ConflictException.php b/src/Exceptions/ConflictException.php new file mode 100644 index 0000000..f91f63f --- /dev/null +++ b/src/Exceptions/ConflictException.php @@ -0,0 +1,10 @@ +pull($request); + } + + public function push(SyncService $watermelon, Request $request): JsonResponse + { + return $watermelon->push($request); + } +} diff --git a/src/SyncService.php b/src/SyncService.php new file mode 100644 index 0000000..795fe3f --- /dev/null +++ b/src/SyncService.php @@ -0,0 +1,165 @@ +models = $models; + } + + public function pull(Request $request): JsonResponse + { + $lastPulledAt = $request->get('last_pulled_at'); + + $timestamp = now()->timestamp; + + $changes = []; + + if ($lastPulledAt === 'null') { + foreach ($this->models as $name => $class) { + $changes[$name] = [ + 'created' => (new $class)::watermelon() + ->get() + ->map->toWatermelonArray(), + 'updated' => [], + 'deleted' => [], + ]; + } + } else { + $lastPulledAt = Carbon::createFromTimestampUTC($lastPulledAt); + + foreach ($this->models as $name => $class) { + $changes[$name] = [ + 'created' => (new $class)::withoutTrashed() + ->where('created_at', '>', $lastPulledAt) + ->watermelon() + ->get() + ->map->toWatermelonArray(), + 'updated' => (new $class)::withoutTrashed() + ->where('created_at', '<=', $lastPulledAt) + ->where('updated_at', '>', $lastPulledAt) + ->watermelon() + ->get() + ->map->toWatermelonArray(), + 'deleted' => (new $class)::onlyTrashed() + ->where('created_at', '<=', $lastPulledAt) + ->where('deleted_at', '>', $lastPulledAt) + ->watermelon() + ->get('watermelon_id') + ->pluck('watermelon_id'), + ]; + } + } + + return response()->json([ + 'changes' => $changes, + 'timestamp' => $timestamp, + ]); + } + + public function push(Request $request): JsonResponse + { + DB::beginTransaction(); + + foreach ($this->models as $name => $class) { + if (!$request->input($name)) { + continue; + } + + collect($request->input("$name.created"))->each(function ($create) use ($class) { + $create = collect((new $class)->toWatermelonArray()) + ->keys() + ->map(function ($col) use ($create) { + return [$col, $create[$col]]; + })->reduce(function ($assoc, $pair) { + list($key, $value) = $pair; + if ($key === 'id') { + $assoc['watermelon_id'] = $value; + } else { + $assoc[$key] = $value; + } + return $assoc; + }, collect()); + + try { + $model = $class::query()->where('watermelon_id', $create->get('watermelon_id'))->firstOrFail(); + $model->update($create->toArray()); + } catch (ModelNotFoundException) { + $class::query()->create($create->toArray()); + } + }); + } + + try { + foreach ($this->models as $name => $class) { + if (!$request->input($name)) { + continue; + } + + collect($request->input("$name.updated"))->each(function ($update) use ($class) { + $update = collect((new $class)->toWatermelonArray()) + ->keys() + ->map(function ($col) use ($update) { + return [$col, $update[$col]]; + })->reduce(function ($assoc, $pair) { + list($key, $value) = $pair; + if ($key === 'id') { + $assoc['watermelon_id'] = $value; + } else { + $assoc[$key] = $value; + } + return $assoc; + }, collect()); + + if ($class::onlyTrashed()->where('watermelon_id', $update->get('watermelon_id'))->count() > 0) { + throw new ConflictException; + } + + try { + $task = $class::query() + ->where('watermelon_id', $update->get('watermelon_id')) + ->watermelon() + ->firstOrFail(); + $task->update($update->toArray()); + } catch (ModelNotFoundException) { + try { + $class::query()->create($update->toArray()); + } catch (QueryException) { + throw new ConflictException; + } + } + }); + } + } catch (ConflictException) { + DB::rollBack(); + + return response()->json('', 409); + } + + foreach ($this->models as $name => $class) { + if (!$request->input($name)) { + continue; + } + + collect($request->input("$name.deleted"))->each(function ($delete) use ($class) { + $class::query()->where('watermelon_id', $delete)->watermelon()->delete(); + }); + } + + DB::commit(); + + return response()->json('', 204); + } +} \ No newline at end of file diff --git a/src/Traits/Watermelon.php b/src/Traits/Watermelon.php new file mode 100644 index 0000000..310f1fe --- /dev/null +++ b/src/Traits/Watermelon.php @@ -0,0 +1,20 @@ + $this->watermelon_id, + ], $this->only($this->watermelonAttributes ?? [])); + + return $attributes; + } +} diff --git a/src/WatermelonServiceProvider.php b/src/WatermelonServiceProvider.php new file mode 100644 index 0000000..7b78886 --- /dev/null +++ b/src/WatermelonServiceProvider.php @@ -0,0 +1,40 @@ +mergeConfigFrom(__DIR__ . '/../config/watermelon.php', 'watermelon'); + + $this->app->singleton(SyncService::class, function () { + return new SyncService(config('watermelon.models')); + }); + } + + /** + * Bootstrap services. + * + * @return void + */ + public function boot() + { + $this->publishes([ + __DIR__ . '/../config/watermelon.php' => base_path('config/watermelon.php'), + ], 'watermelon-config'); + + Route::middleware(config('watermelon.middleware'))->group(function () { + Route::get(config('watermelon.route'), [SyncController::class, 'pull']); + Route::post(config('watermelon.route'), [SyncController::class, 'push']); + }); + } +} diff --git a/tests/Feature/ModelAuthorizationTest.php b/tests/Feature/ModelAuthorizationTest.php new file mode 100644 index 0000000..f34eba2 --- /dev/null +++ b/tests/Feature/ModelAuthorizationTest.php @@ -0,0 +1,221 @@ + TaskScoped::class, + ]); + } + + /** @test */ + public function it_can_apply_a_models_watermelon_scope_with_no_last_pulled_at_timestamp(): void + { + TaskScoped::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + TaskScoped::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ]); + + TaskScoped::query()->create([ + 'watermelon_id' => 'thirdtaskid', + 'content' => 'Third Task', + 'is_completed' => false, + ]); + + $response = $this->json('GET', '/sync'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ], + [ + 'id' => 'thirdtaskid', + 'content' => 'Third Task', + 'is_completed' => false, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_apply_a_models_watermelon_scope_with_a_last_pulled_at_timestamp(): void + { + TaskScoped::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + TaskScoped::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ]); + + TaskScoped::query()->create([ + 'watermelon_id' => 'thirdtaskid', + 'content' => 'Third Task', + 'is_completed' => false, + ]); + + $lastPulledAt = now()->subMinutes(10)->timestamp; + $response = $this->json('GET', '/sync?last_pulled_at='.$lastPulledAt); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ], + [ + 'id' => 'thirdtaskid', + 'content' => 'Third Task', + 'is_completed' => false, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_throws_an_exception_and_rolls_back_changes_when_trying_to_update_a_model_restricted_by_watermelon_scope(): void + { + TaskScoped::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + + TaskScoped::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [], + 'updated' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'Updated First Task', + 'is_completed' => true, + ], + [ + 'id' => 'secondtaskid', + 'content' => 'Updated Second Task', + 'is_completed' => false, + ], + ], + 'deleted' => [], + ], + ]); + $response->assertStatus(409); + + $this->assertDatabaseCount('tasks', 2); + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + } + + /** @test */ + public function it_does_not_throw_an_error_but_does_not_delete_a_model_restricted_by_watermelon_scope(): void + { + TaskScoped::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + 'deleted_at' => null, + ]); + + TaskScoped::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => [ + 'firsttaskid', + 'secondtaskid', + ], + ], + ]); + $response->assertNoContent(); + + $this->assertDatabaseCount('tasks', 2); + $this->assertSoftDeleted('tasks', [ + 'watermelon_id' => 'firsttaskid', + ]); + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + 'deleted_at' => null, + ]); + } +} diff --git a/tests/Feature/MultipleModelPullTest.php b/tests/Feature/MultipleModelPullTest.php new file mode 100644 index 0000000..eb31260 --- /dev/null +++ b/tests/Feature/MultipleModelPullTest.php @@ -0,0 +1,260 @@ + Task::class, + 'projects' => Project::class, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_for_multiple_models_without_a_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'taskid', + 'content' => 'Task', + 'is_completed' => true, + ]); + Project::query()->create([ + 'watermelon_id' => 'projectid', + 'name' => 'Project', + ]); + + $response = $this->json('GET', '/sync'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'taskid', + 'content' => 'Task', + 'is_completed' => true, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + 'projects' => [ + 'created' => [ + [ + 'id' => 'projectid', + 'name' => 'Project', + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_for_multiple_models_with_a_null_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'taskid', + 'content' => 'Task', + 'is_completed' => false, + ]); + Project::query()->create([ + 'watermelon_id' => 'projectid', + 'name' => 'Project', + ]); + + $response = $this->json('GET', '/sync?last_pulled_at=null'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'taskid', + 'content' => 'Task', + 'is_completed' => false, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + 'projects' => [ + 'created' => [ + [ + 'id' => 'projectid', + 'name' => 'Project', + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_for_multiple_models_with_a_zero_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'taskid', + 'content' => 'Task', + 'is_completed' => true, + ]); + Project::query()->create([ + 'watermelon_id' => 'projectid', + 'name' => 'Project', + ]); + + $response = $this->json('GET', '/sync?last_pulled_at=0'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'taskid', + 'content' => 'Task', + 'is_completed' => true, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + 'projects' => [ + 'created' => [ + [ + 'id' => 'projectid', + 'name' => 'Project', + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_for_multiple_models_with_a_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + Project::query()->create([ + 'watermelon_id' => 'firstprojectid', + 'name' => 'First Project', + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(9), + 'updated_at' => now()->subMinutes(9), + ]); + Project::query()->create([ + 'watermelon_id' => 'secondprojectid', + 'name' => 'Second Project', + 'created_at' => now()->subMinutes(9), + 'updated_at' => now()->subMinutes(9), + ]); + + Task::query()->create([ + 'watermelon_id' => 'updatedtaskid', + 'content' => 'Updated Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + ]); + Project::query()->create([ + 'watermelon_id' => 'updatedprojectid', + 'name' => 'Updated Project', + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + ]); + + Task::query()->create([ + 'watermelon_id' => 'deletedtaskid', + 'content' => 'Deleted Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + 'deleted_at' => now()->subMinutes(9), + ]); + Project::query()->create([ + 'watermelon_id' => 'deletedprojectid', + 'name' => 'Deleted Project', + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + 'deleted_at' => now()->subMinutes(9), + ]); + + // last_pulled_at = 10 minutes ago + $lastPulledAt = now()->subMinutes(10)->timestamp; + $response = $this->json('GET', '/sync?last_pulled_at='.$lastPulledAt); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ], + ], + 'updated' => [ + [ + 'id' => 'updatedtaskid', + 'content' => 'Updated Task', + 'is_completed' => false, + ], + ], + 'deleted' => ['deletedtaskid'], + ], + 'projects' => [ + 'created' => [ + [ + 'id' => 'secondprojectid', + 'name' => 'Second Project', + ], + ], + 'updated' => [ + [ + 'id' => 'updatedprojectid', + 'name' => 'Updated Project', + ], + ], + 'deleted' => ['deletedprojectid'], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } +} diff --git a/tests/Feature/MultipleModelPushTest.php b/tests/Feature/MultipleModelPushTest.php new file mode 100644 index 0000000..1dbe228 --- /dev/null +++ b/tests/Feature/MultipleModelPushTest.php @@ -0,0 +1,339 @@ + Task::class, + 'projects' => Project::class, + ]); + } + + /** @test */ + public function it_persists_push_requests_for_multiple_models(): void + { + $firstTask = Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + $firstProject = Project::query()->create([ + 'watermelon_id' => 'firstprojectid', + 'name' => 'First Project', + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => false, + 'deleted_at' => null, + ]); + Project::query()->create([ + 'watermelon_id' => 'secondprojectid', + 'name' => 'Second Project', + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ], + ], + 'updated' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'Updated Content', + 'is_completed' => true, + ], + ], + 'deleted' => ['secondtaskid'], + ], + 'projects' => [ + 'created' => [ + [ + 'id' => 'newprojectid', + 'name' => 'New Project', + ], + ], + 'updated' => [ + [ + 'id' => 'firstprojectid', + 'name' => 'Updated Name', + ], + + ], + 'deleted' => ['secondprojectid'], + ], + ]); + $response->assertNoContent(); + + $firstTask->refresh(); + $this->assertEquals('Updated Content', $firstTask->content); + $this->assertTrue($firstTask->is_completed); + + $firstProject->refresh(); + $this->assertEquals('Updated Name', $firstProject->name); + + $this->assertSoftDeleted('tasks', ['watermelon_id' => 'secondtaskid']); + $this->assertSoftDeleted('projects', ['watermelon_id' => 'secondprojectid']); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ]); + $this->assertDatabaseHas('projects', [ + 'watermelon_id' => 'newprojectid', + 'name' => 'New Project', + ]); + } + + /** @test */ + public function it_rolls_back_push_request_changes_for_all_models_when_an_error_is_thrown_for_one_model(): void + { + Task::query()->create([ + 'watermelon_id' => 'regulartaskid', + 'content' => 'Regular Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'oldtaskid', + 'content' => 'Old Task', + 'is_completed' => false, + 'deleted_at' => null, + ]); + + Project::query()->create([ + 'watermelon_id' => 'deletedprojectid', + 'name' => 'Deleted Project', + 'deleted_at' => now()->subMinute(), + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ], + ], + 'updated' => [ + [ + 'id' => 'regulartaskid', + 'content' => 'Updated Regular Task', + 'is_completed' => true, + ], + [ + 'id' => 'deletedtaskid', + 'content' => 'New Content', + 'is_completed' => true, + ], + ], + 'deleted' => ['oldtaskid'], + ], + 'projects' => [ + 'created' => [], + 'updated' => [ + [ + 'id' => 'deletedprojectid', + 'name' => 'New Name', + ], + ], + 'deleted' => [], + ], + ]); + $response->assertStatus(409); + + $this->assertDatabaseMissing('tasks', [ + 'watermelon_id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ]); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'regulartaskid', + 'content' => 'Regular Task', + 'is_completed' => false, + ]); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'oldtaskid', + 'deleted_at' => null, + ]); + + $this->assertDatabaseHas('projects', [ + 'watermelon_id' => 'deletedprojectid', + 'deleted_at' => now()->subMinute(), + ]); + } + + /** @test */ + public function it_persists_push_requests_even_when_one_model_of_many_is_missing(): void + { + $firstTask = Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => false, + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ], + ], + 'updated' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'Updated Content', + 'is_completed' => true, + ], + ], + 'deleted' => ['secondtaskid'], + ], + ]); + $response->assertNoContent(); + + $firstTask->refresh(); + $this->assertEquals('Updated Content', $firstTask->content); + $this->assertTrue($firstTask->is_completed); + + $this->assertSoftDeleted('tasks', ['watermelon_id' => 'secondtaskid']); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ]); + } + + /** @test */ + public function it_persists_push_requests_even_when_an_unknown_model_is_encountered_among_many(): void + { + $firstTask = Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + $firstProject = Project::query()->create([ + 'watermelon_id' => 'firstprojectid', + 'name' => 'First Project', + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => false, + 'deleted_at' => null, + ]); + Project::query()->create([ + 'watermelon_id' => 'secondprojectid', + 'name' => 'Second Project', + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ], + ], + 'updated' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'Updated Content', + 'is_completed' => true, + ], + ], + 'deleted' => ['secondtaskid'], + ], + 'unknown' => [ + 'created' => [ + [ + 'id' => 'newunknown', + 'value' => 'New Unknown', + ], + ], + 'updated' => [ + [ + 'id' => 'updatedunknown', + 'value' => 'Updated Unknown', + ], + ], + 'deleted' => ['deletedunknown'], + ], + 'projects' => [ + 'created' => [ + [ + 'id' => 'newprojectid', + 'name' => 'New Project', + ], + ], + 'updated' => [ + [ + 'id' => 'firstprojectid', + 'name' => 'Updated Name', + ], + + ], + 'deleted' => ['secondprojectid'], + ], + ]); + $response->assertNoContent(); + + $firstTask->refresh(); + $this->assertEquals('Updated Content', $firstTask->content); + $this->assertTrue($firstTask->is_completed); + + $firstProject->refresh(); + $this->assertEquals('Updated Name', $firstProject->name); + + $this->assertSoftDeleted('tasks', ['watermelon_id' => 'secondtaskid']); + $this->assertSoftDeleted('projects', ['watermelon_id' => 'secondprojectid']); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ]); + $this->assertDatabaseHas('projects', [ + 'watermelon_id' => 'newprojectid', + 'name' => 'New Project', + ]); + } +} diff --git a/tests/Feature/NoModelPullTest.php b/tests/Feature/NoModelPullTest.php new file mode 100644 index 0000000..10e0a8e --- /dev/null +++ b/tests/Feature/NoModelPullTest.php @@ -0,0 +1,67 @@ +json('GET', '/sync'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_without_models_and_null_last_pulled_at(): void + { + $response = $this->json('GET', '/sync?last_pulled_at=null'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_without_models_and_zero_last_pulled_at(): void + { + $response = $this->json('GET', '/sync?last_pulled_at=0'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_without_models_and_last_pulled_at(): void + { + $lastPulledAt = now()->subMinutes(10)->timestamp; + $response = $this->json('GET', '/sync?last_pulled_at='.$lastPulledAt); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [], + 'timestamp' => now()->timestamp, + ]); + } +} diff --git a/tests/Feature/NoModelPushTest.php b/tests/Feature/NoModelPushTest.php new file mode 100644 index 0000000..c13887e --- /dev/null +++ b/tests/Feature/NoModelPushTest.php @@ -0,0 +1,46 @@ +json('POST', '/sync'); + $response->assertNoContent(); + } + + /** @test */ + public function it_can_respond_to_push_requests_with_no_models_and_unknown_data(): void + { + $response = $this->json('POST', '/sync', [ + 'unknown' => [ + 'created' => [ + [ + 'id' => 'newunknown', + 'value' => 'New Value', + ], + ], + 'updated' => [ + [ + 'id' => 'updatedunknown', + 'value' => 'Updated Value', + ], + ], + 'deleted' => ['deletedunknown'], + ], + ]); + $response->assertNoContent(); + } +} diff --git a/tests/Feature/SingleModelPullTest.php b/tests/Feature/SingleModelPullTest.php new file mode 100644 index 0000000..b7efb8c --- /dev/null +++ b/tests/Feature/SingleModelPullTest.php @@ -0,0 +1,345 @@ + Task::class, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_no_data_and_no_last_pulled_at_timestamp(): void + { + $response = $this->json('GET', '/sync'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_no_data_and_a_null_last_pulled_at_timestamp(): void + { + $response = $this->json('GET', '/sync?last_pulled_at=null'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_no_data_and_a_zero_last_pulled_at_timestamp(): void + { + $response = $this->json('GET', '/sync?last_pulled_at=0'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_no_changes_and_a_last_pulled_at_timestamp(): void + { + $lastPulledAt = now()->subMinutes(10)->timestamp; + $response = $this->json('GET', '/sync?last_pulled_at='.$lastPulledAt); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_data_and_no_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ]); + + Task::query()->create([ + 'watermelon_id' => 'deletedtaskid', + 'content' => 'Deleted Task', + 'is_completed' => true, + 'deleted_at' => now(), + ]); + + $response = $this->json('GET', '/sync'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ], + [ + 'id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_data_and_a_null_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ]); + + Task::query()->create([ + 'watermelon_id' => 'deletedtaskid', + 'content' => 'Deleted Task', + 'is_completed' => true, + 'deleted_at' => now(), + ]); + + $response = $this->json('GET', '/sync?last_pulled_at=null'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ], + [ + 'id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_data_and_a_zero_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ]); + + Task::query()->create([ + 'watermelon_id' => 'deletedtaskid', + 'content' => 'Deleted Task', + 'is_completed' => true, + 'deleted_at' => now(), + ]); + + $response = $this->json('GET', '/sync?last_pulled_at=0'); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ], + [ + 'id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ], + ], + 'updated' => [], + 'deleted' => [], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_data_and_a_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(9), + 'updated_at' => now()->subMinutes(9), + ]); + + Task::query()->create([ + 'watermelon_id' => 'updatedtaskid', + 'content' => 'Updated Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + ]); + + Task::query()->create([ + 'watermelon_id' => 'deletedtaskid', + 'content' => 'Deleted Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + 'deleted_at' => now()->subMinutes(9), + ]); + + $lastPulledAt = now()->subMinutes(10)->timestamp; + $response = $this->json('GET', '/sync?last_pulled_at='.$lastPulledAt); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + ], + ], + 'updated' => [ + [ + 'id' => 'updatedtaskid', + 'content' => 'Updated Task', + 'is_completed' => false, + ], + ], + 'deleted' => ['deletedtaskid'], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } + + /** @test */ + public function it_can_respond_to_pull_requests_with_lots_of_deleted_records_and_a_last_pulled_at_timestamp(): void + { + Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(11), + 'deleted_at' => now()->subMinutes(11), + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(11), + 'updated_at' => now()->subMinutes(9), + 'deleted_at' => now()->subMinutes(9), + ]); + + Task::query()->create([ + 'watermelon_id' => 'thirdtaskid', + 'content' => 'Third Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(9), + 'updated_at' => now()->subMinutes(9), + 'deleted_at' => now()->subMinutes(5), + ]); + + $lastPulledAt = now()->subMinutes(10)->timestamp; + $response = $this->json('GET', '/sync?last_pulled_at='.$lastPulledAt); + $response->assertStatus(200); + $response->assertExactJson([ + 'changes' => [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => [ + 'secondtaskid', + ], + ], + ], + 'timestamp' => now()->timestamp, + ]); + } +} diff --git a/tests/Feature/SingleModelPushTest.php b/tests/Feature/SingleModelPushTest.php new file mode 100644 index 0000000..bca0747 --- /dev/null +++ b/tests/Feature/SingleModelPushTest.php @@ -0,0 +1,259 @@ + Task::class, + ]); + } + + /** @test */ + public function it_ignores_changes_for_models_not_in_the_watermelon_models_config_array(): void + { + $response = $this->json('POST', '/sync', [ + 'unknown' => [ + 'created' => [ + [ + 'id' => 'newunknown', + 'value' => 'New Unknown', + ], + ], + 'updated' => [ + [ + 'id' => 'updatedunknown', + 'value' => 'Updated Unknown', + ], + ], + 'deleted' => ['deletedunknown'], + ], + ]); + $response->assertNoContent(); + } + + /** @test */ + public function it_persists_push_request_changes(): void + { + $firstTask = Task::query()->create([ + 'watermelon_id' => 'firsttaskid', + 'content' => 'First Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'secondtaskid', + 'content' => 'Second Task', + 'is_completed' => false, + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ], + ], + 'updated' => [ + [ + 'id' => 'firsttaskid', + 'content' => 'Updated Content', + 'is_completed' => true, + ], + ], + 'deleted' => ['secondtaskid'], + ], + ]); + $response->assertNoContent(); + + $firstTask->refresh(); + $this->assertEquals('Updated Content', $firstTask->content); + $this->assertTrue($firstTask->is_completed); + + $this->assertSoftDeleted('tasks', ['watermelon_id' => 'secondtaskid']); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ]); + } + + /** @test */ + public function it_updates_the_record_if_there_is_an_attempt_to_create_an_existing_record(): void + { + Task::query()->create([ + 'watermelon_id' => 'taskid', + 'content' => 'Existing Task', + 'is_completed' => true, + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'taskid', + 'content' => 'New Content', + 'is_completed' => false, + ] + ], + 'updated' => [], + 'deleted' => [], + ], + ]); + $response->assertNoContent(); + + $this->assertDatabaseCount('tasks', 1); + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'taskid', + 'content' => 'New Content', + 'is_completed' => false, + 'created_at' => now()->subMinutes(10), + 'updated_at' => now(), + 'deleted_at' => null, + ]); + } + + /** @test */ + public function it_creates_a_record_if_there_is_an_attempt_to_update_a_non_existent_record(): void + { + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [], + 'updated' => [ + [ + 'id' => 'taskid', + 'content' => 'New Content', + 'is_completed' => true, + ] + ], + 'deleted' => [], + ], + ]); + $response->assertNoContent(); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'taskid', + 'content' => 'New Content', + 'is_completed' => true, + 'created_at' => now(), + 'updated_at' => now(), + 'deleted_at' => null, + ]); + } + + /** @test */ + public function it_does_not_throw_an_error_if_there_is_an_attempt_to_delete_an_already_deleted_record(): void + { + $deletedAt = now()->subMinute(); + + $task = Task::query()->create([ + 'watermelon_id' => 'taskid', + 'content' => 'Task', + 'is_completed' => false, + 'deleted_at' => $deletedAt, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [], + 'updated' => [], + 'deleted' => ['taskid'], + ], + ]); + $response->assertNoContent(); + + $this->assertEquals($deletedAt, $task->fresh()->deleted_at); + } + + /** @test */ + public function it_rolls_back_push_request_changes_if_there_is_an_attempt_to_update_a_deleted_record(): void + { + Task::query()->create([ + 'watermelon_id' => 'regulartaskid', + 'content' => 'Regular Task', + 'is_completed' => false, + ]); + + Task::query()->create([ + 'watermelon_id' => 'deletedtaskid', + 'content' => 'Deleted Task', + 'is_completed' => false, + 'deleted_at' => now()->subMinute(), + ]); + + Task::query()->create([ + 'watermelon_id' => 'oldtaskid', + 'content' => 'Old Task', + 'is_completed' => false, + 'deleted_at' => null, + ]); + + $response = $this->json('POST', '/sync', [ + 'tasks' => [ + 'created' => [ + [ + 'id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ], + ], + 'updated' => [ + [ + 'id' => 'regulartaskid', + 'content' => 'Updated Regular Task', + 'is_completed' => true, + ], + [ + 'id' => 'deletedtaskid', + 'content' => 'New Content', + 'is_completed' => true, + ], + ], + 'deleted' => ['oldtaskid'], + ], + ]); + $response->assertStatus(409); + + $this->assertDatabaseMissing('tasks', [ + 'watermelon_id' => 'newtaskid', + 'content' => 'New Task', + 'is_completed' => false, + ]); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'regulartaskid', + 'content' => 'Regular Task', + 'is_completed' => false, + ]); + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'deletedtaskid', + 'deleted_at' => now()->subMinute(), + ]); + + $this->assertDatabaseHas('tasks', [ + 'watermelon_id' => 'oldtaskid', + 'deleted_at' => null, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..72f06ff --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,20 @@ +loadMigrationsFrom(__DIR__ . '/database'); + } +} diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php new file mode 100644 index 0000000..0e4e75a --- /dev/null +++ b/tests/Unit/ModelTest.php @@ -0,0 +1,55 @@ + 'watermelonidvalue', + 'name' => 'Anonymous Class', + 'views' => 42, + 'published' => true, + ]; + }; + + $this->assertSame([ + 'id' => 'watermelonidvalue' + ], $model->toWatermelonArray()); + } + + /** @test */ + public function it_returns_all_watermelon_attributes_along_with_the_id() + { + $model = new class extends Model { + use Watermelon; + + protected $attributes = [ + 'watermelon_id' => 'watermelonidvalue', + 'name' => 'Anonymous Class', + 'views' => 42, + 'published' => true, + ]; + + protected array $watermelonAttributes = [ + 'name', + 'published', + ]; + }; + + $this->assertSame([ + 'id' => 'watermelonidvalue', + 'name' => 'Anonymous Class', + 'published' => true, + ], $model->toWatermelonArray()); + } +} diff --git a/tests/database/2021_08_07_000000_create_projects_table.php b/tests/database/2021_08_07_000000_create_projects_table.php new file mode 100644 index 0000000..20e9cfc --- /dev/null +++ b/tests/database/2021_08_07_000000_create_projects_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('watermelon_id')->unique(); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('projects'); + } +} diff --git a/tests/database/2021_08_07_000000_create_tasks_table.php b/tests/database/2021_08_07_000000_create_tasks_table.php new file mode 100644 index 0000000..c0e9579 --- /dev/null +++ b/tests/database/2021_08_07_000000_create_tasks_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('watermelon_id')->unique(); + $table->string('content'); + $table->boolean('is_completed'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('tasks'); + } +} diff --git a/tests/models/Project.php b/tests/models/Project.php new file mode 100644 index 0000000..073f51f --- /dev/null +++ b/tests/models/Project.php @@ -0,0 +1,17 @@ + 'bool', + ]; + + protected array $watermelonAttributes = [ + 'content', + 'is_completed', + ]; +} diff --git a/tests/models/TaskScoped.php b/tests/models/TaskScoped.php new file mode 100644 index 0000000..7f9153c --- /dev/null +++ b/tests/models/TaskScoped.php @@ -0,0 +1,30 @@ + 'bool', + ]; + + public function scopeWatermelon(Builder $query) + { + return $query->where('is_completed', false); + } + + protected array $watermelonAttributes = [ + 'content', + 'is_completed', + ]; +}