diff --git a/.env.ci b/.env.ci index 5b3d9af2..d1ef4db2 100644 --- a/.env.ci +++ b/.env.ci @@ -1,19 +1,22 @@ -APP_NAME=Laravel +APP_NAME=solidtime APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost +APP_FORCE_HTTPS=false +SESSION_SECURE_COOKIE=false LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=pgsql -DB_HOST=127.0.0.1 -DB_PORT=5432 -DB_DATABASE=laravel -DB_USERNAME=root -DB_PASSWORD=root +DB_CONNECTION=pgsql_test + +DB_TEST_HOST=127.0.0.1 +DB_TEST_PORT=5432 +DB_TEST_DATABASE=laravel +DB_TEST_USERNAME=root +DB_TEST_PASSWORD=root BROADCAST_DRIVER=log CACHE_DRIVER=file diff --git a/.env.example b/.env.example index ed50bd57..64023886 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_DEBUG=true APP_URL=https://solidtime.test APP_FORCE_HTTPS=true +SESSION_SECURE_COOKIE=true SUPER_ADMINS=admin@example.com @@ -12,12 +13,19 @@ LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=pgsql + DB_HOST=pgsql DB_PORT=5432 DB_DATABASE=laravel DB_USERNAME=root DB_PASSWORD=root +DB_TEST_HOST=pgsql_test +DB_TEST_PORT=5432 +DB_TEST_DATABASE=laravel +DB_TEST_USERNAME=root +DB_TEST_PASSWORD=root + BROADCAST_DRIVER=log CACHE_DRIVER=file FILESYSTEM_DISK=local diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index a5d7dd47..740d2d53 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest services: - pgsql: + pgsql_test: image: postgres:15 env: PGPASSWORD: 'root' diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 141b73e2..5db1823b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -11,7 +11,7 @@ jobs: services: mailpit: image: 'axllent/mailpit:latest' - pgsql: + pgsql_test: image: postgres:15 env: PGPASSWORD: 'root' diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 92d9a591..de310a2d 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -48,7 +48,8 @@ class TimeEntryIndexRequest extends FormRequest ], // Filter only time entries that are active (have no end date, are still running) 'active' => [ - 'boolean', + 'string', + 'in:true,false', ], // Limit the number of returned time entries 'limit' => [ diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php index 12c785e6..3c02285a 100644 --- a/app/Service/Import/ImportDatabaseHelper.php +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -43,11 +43,15 @@ class ImportDatabaseHelper private int $createdCount; + /** + * @var array> + */ private array $validate; /** * @param class-string $model * @param array $identifiers + * @param array> $validate */ public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = []) { diff --git a/config/database.php b/config/database.php index f75f0d9f..5ac598cc 100644 --- a/config/database.php +++ b/config/database.php @@ -80,6 +80,21 @@ return [ 'sslmode' => 'prefer', ], + 'pgsql_test' => [ + 'driver' => 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_TEST_HOST', '127.0.0.1'), + 'port' => env('DB_TEST_PORT', '5432'), + 'database' => env('DB_TEST_DATABASE', 'forge'), + 'username' => env('DB_TEST_USERNAME', 'forge'), + 'password' => env('DB_TEST_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b59a486d..2e057b52 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,6 +8,7 @@ namespace Database\Seeders; use App\Models\Client; use App\Models\Organization; use App\Models\Project; +use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; @@ -109,6 +110,7 @@ class DatabaseSeeder extends Seeder { DB::table((new TimeEntry())->getTable())->delete(); DB::table((new Task())->getTable())->delete(); + DB::table((new Tag())->getTable())->delete(); DB::table((new Project())->getTable())->delete(); DB::table((new Client())->getTable())->delete(); DB::table((new User())->getTable())->delete(); diff --git a/openapi.json.client.ts b/openapi.json.client.ts index 5366233e..d9208c6f 100644 --- a/openapi.json.client.ts +++ b/openapi.json.client.ts @@ -10,6 +10,9 @@ const ClientResource = z }) .passthrough(); const ClientCollection = z.array(ClientResource); +const v1_import_import_Body = z + .object({ type: z.string(), data: z.string() }) + .passthrough(); const OrganizationResource = z .object({ id: z.string(), name: z.string(), is_personal: z.string() }) .passthrough(); @@ -72,10 +75,21 @@ const updateTimeEntry_Body = z tags: z.union([z.array(z.string()), z.null()]).optional(), }) .passthrough(); +const UserResource = z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + role: z.string(), + is_placeholder: z.boolean(), + }) + .passthrough(); +const UserCollection = z.array(UserResource); export const schemas = { ClientResource, ClientCollection, + v1_import_import_Body, OrganizationResource, ProjectResource, ProjectCollection, @@ -87,6 +101,8 @@ export const schemas = { TimeEntryCollection, createTimeEntry_Body, updateTimeEntry_Body, + UserResource, + UserCollection, }; const endpoints = makeApi([ @@ -306,6 +322,76 @@ const endpoints = makeApi([ }, ], }, + { + method: 'post', + path: '/v1/organizations/:organization/import', + alias: 'v1.import.import', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: v1_import_import_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z + .object({ + report: z + .object({ + clients: z + .object({ created: z.number().int() }) + .passthrough(), + projects: z + .object({ created: z.number().int() }) + .passthrough(), + tasks: z + .object({ created: z.number().int() }) + .passthrough(), + 'time-entries': z + .object({ created: z.number().int() }) + .passthrough(), + tags: z + .object({ created: z.number().int() }) + .passthrough(), + users: z + .object({ created: z.number().int() }) + .passthrough(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 400, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization/projects', @@ -664,7 +750,7 @@ const endpoints = makeApi([ { name: 'active', type: 'Query', - schema: z.string().optional(), + schema: z.enum(['true', 'false']).optional(), }, { name: 'limit', @@ -824,6 +910,78 @@ const endpoints = makeApi([ }, ], }, + { + method: 'get', + path: '/v1/organizations/:organization/users', + alias: 'v1.users.index', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: UserCollection }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/users/:user/invite-placeholder', + alias: 'v1.users.invite-placeholder', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'user', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.string(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, ]); export const api = new Zodios('http://solidtime.test/api', endpoints); diff --git a/phpunit.xml b/phpunit.xml index f5ab132a..d5881ea7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,8 +21,7 @@ - - + diff --git a/playwright.config.ts b/playwright.config.ts index 5a1b230e..1c552630 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: process.env.CI ? 'list' : 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index b4d30d26..cd9c146b 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -143,7 +143,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Act $response = $this->getJson(route('api.v1.time-entries.index', [ $data->organization->getKey(), - 'active' => true, + 'active' => 'true', 'user_id' => $data->user->getKey(), ]));