feat: Initial commit for Saqel Platform architecture, backend, Docker, and documentation

This commit is contained in:
Hamza-Ayed
2026-08-26 15:45:55 +03:00
commit d53b64500a
177 changed files with 19909 additions and 0 deletions
@@ -0,0 +1,60 @@
# Assertions
## Arrange, Act, Assert
Write each test in three parts: setup, one action, and assertions. Put one blank line between them so readers can identify each part without comments.
Keep each test self-contained. Do not use values created by another test.
## How to Find the Correct Assertion
First identify the subject of the check, then find an assertion designed for it. A subject-specific assertion identifies the incorrect value when the test fails.
1. Search Laravel's assertions for framework subjects such as responses, the database, sessions, models, queues, events, mail, and notifications.
2. Fetch `https://docs.phpunit.de/en/13.3/assertions.html` for the assertions of PHPUnit for a plain value, a type, a format, or a shape.
3. Build the check by hand only if no assertion exists for the subject.
4. Confirm the name in the documentation before you use it. Do not write an assertion that you did not confirm.
Use the assertion in this table for each subject.
| Subject | Assertion to use |
| --- | --- |
| A return value, the state of an object, or a transformation of a value | `assertSame()`, or the assertion for the type |
| An HTTP status, JSON, a session, or Inertia | a Laravel response assertion |
| The state in the database | a Laravel database assertion |
| The existence of a model | `assertModelExists($model)` rather than `assertDatabaseHas('users', ['id' => $user->id])` |
Use `assertSame()` and not `assertEquals()`, because `assertSame()` also compares the type.
Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered.
## The Assertion with a Name for a Response
Use a named response assertion, such as `assertNotFound()`, rather than `assertStatus(404)`. A failure then identifies the broken contract. Laravel provides named assertions for commonly tested status codes.
Group the assertions for one subject together. Start a new group when the subject changes.
## Assert a Known Value
Write the expected value in the test, or calculate the expected value by a different method. Do not calculate the expected value with the logic of the implementation, because the test then passes when that logic is wrong.
```php
// The test calculates the value with the logic of the implementation.
$expected = now()->subHours(24)->floorSeconds(30)->toJson();
$this->assertSame($expected, $from);
// The test sets a fixed input and asserts a known value.
$this->travelTo('2025-01-01 00:00:00');
$this->assertSame('2024-12-31T00:00:00.000000Z', $from);
```
## Assert the Complete Result
A status code is not the complete result of a write operation. Assert each of the following if the operation changes it:
- the response or the return value
- the state in the database
- the jobs and the events that the operation dispatches
- the notifications and the mail that the operation sends
On the failure path, assert that the operation makes none of these changes. A test that asserts only `assertOk()` passes even when the application saves no record.
@@ -0,0 +1,48 @@
# Endpoint Tests
## How to Write the Test
Fetch `https://laravel.com/framework/docs/http-tests` for the request helpers, the authentication helpers, and the response assertions. Confirm the name before you use it, and do not guess an assertion.
Choose an assertion based on the subject of the check: the status, a header, a redirect, the JSON body, the session, a validation error, or the view. Laravel provides a named assertion for each subject that identifies the incorrect value.
## The Coverage of an Endpoint
Write a test for each applicable case:
- The request has missing or invalid authentication.
- The request comes from a different tenant, team, or organization.
- The user has an insufficient role or permission.
- The request does not satisfy a route or scope constraint.
- The request fails the validation.
- The request is valid. Assert both the response and the persisted state.
Assert the application's actual behavior rather than a generic status code. An API returns `401` for a missing or invalid token, while a browser endpoint redirects to the sign-in route.
## The Isolation of a Tenant
Assert the status code returned for a cross-tenant request. Use `404` rather than `403` when one tenant must not learn that another tenant's record exists, because `403` confirms its existence.
## Test Authorization at the Policy Level
An HTTP test shows that the endpoint performs authorization. It cannot identify which mechanism refused the request because middleware, a policy, and a call to `abort()` can all return `403`.
- Assert the complete matrix of the permissions against the policy or the gate. A failure then names the rule that is not correct.
- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization.
- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists.
## The Validation
- Write one test for each validation rule when each failure represents a separate contract.
- Write one test with an empty payload to assert several required fields together.
- Give the status code in the name of a test for an API.
- Assert the text of the message that the user gets. A message that is present but wrong is a defect.
- Use a data provider with the `#[DataProvider]` attribute for input values that need the same setup and the same assertions. Use the `#[TestWith]` attribute for a small set of values.
Send an input value that is not valid through the application, and assert the error. Do not assert that an array of rules contains a string, because that assertion tests the declaration and not the behavior. Use such an assertion only for a rule that no request can reach, and write the reason in the test.
### Which Layer Owns Which Case
The rule-class test owns the matrix of values that pass and fail. The endpoint test proves that the endpoint applies the rule and that the user receives the message.
When both tests contain the matrix, move it to the rule-class test and retain one case in the endpoint test. Never remove the last case, because the rule-class test still passes if the request omits the rule. The same division applies to policies, scopes, and other classes called by a request.
@@ -0,0 +1,35 @@
# How to Find Test Framework Features
PHPUnit and Laravel provide features for most testing needs. Find an existing feature before implementing the behavior by hand.
- Give `search-docs` the capability you need rather than the name of a method you remember. It returns Laravel testing documentation for the installed version.
- Fetch `https://phpunit.de/documentation.html` for version-specific PHPUnit attributes, assertions, and command-line options.
- If a search returns no results, tell the user that the installed version does not provide the feature. Do not write an API that you have not confirmed.
Search for a feature in this table before you write the code by hand.
| Work that you need | Term to search for |
| --- | --- |
| Run one test method with many input values | data provider, `#[DataProvider]`, `#[TestWith]` |
| Run one test only after another test passes | `#[Depends]` |
| Select or skip a set of tests in one run | `#[Group]`, `--group`, `--exclude-group` |
| Skip a test on a version or on a missing extension | `#[RequiresPhp]`, `#[RequiresPhpExtension]` |
| Find a test that depends on the order of the run | `--order-by=random` |
| Reduce the time of a slow suite | ParaTest, `--cache-result` |
| Stop the run at the first failure while you debug | `--stop-on-failure`, `--filter` |
## The Assertions of Laravel
Laravel provides assertions for each part of the framework. Fetch `https://laravel.com/framework/docs/testing` for the complete list, and search for an assertion before building a check by hand. Examples include `assertDatabaseHas()`, `assertModelExists()`, `assertSoftDeleted()`, response assertions such as `assertRedirectToRoute()` and `assertJsonPath()`, and fake assertions such as `Queue::assertPushed()` and `Notification::assertSentTo()`.
A hand-built check fails with `false is not true`, which identifies nothing. A framework assertion names the incorrect table, value, or response, so the failure indicates what to fix.
```php
// The failure says that false is not true.
// Instead of this
$this->assertTrue(User::where('email', 'taylor@laravel.com')->exists());
// Use this
// The failure names the table and the attributes that it did not find.
$this->assertDatabaseHas('users', ['email' => 'taylor@laravel.com']);
```
@@ -0,0 +1,52 @@
# Fakes, Mocks, and Determinism
Tests that depend on actual time, randomness, sleeping, or network calls can fail for reasons unrelated to the code under test. Control all four.
## How to Isolate a Dependency
Fetch `https://laravel.com/framework/docs/mocking` for Laravel's fakes, facade doubles, and fake assertions. Confirm each name before using it.
Identify the dependency, then choose the first applicable option. A framework fake preserves the real code path, while a mock replaces the dependency.
1. Use framework fakes for facades such as events, queues, mail, notifications, storage, the HTTP client, time, and sleep.
2. Use the fake implementation of the project for a service of the project, if such a fake exists.
3. Use a mock for a container-resolved contract only when the real implementation leaves the process or is nondeterministic.
4. Use the real implementation for everything else, including the database.
## The Fakes
- Create each fake inside the test method that needs it. Do not create fakes in `setUp()`.
- Pass class names to `Event::fake()` and `Queue::fake()` when you know which classes the code dispatches. A fake without class names can hide an unexpected dispatch.
- Use a fake without class names only when the test asserts the complete result, including a call to `assertNothingPushed()`.
- Write one assertion for each fake. The assertion states that the code dispatches the item, or that the code does not dispatch the item.
- Assert the data of a job or of an event if that data is part of the behavior.
- Use `Exceptions::fake()` to assert that the application reports the correct exception. Do not use `withoutExceptionHandling()`, because it changes the response under test.
Create prerequisite factory records before calling `Event::fake()`. Factories use model events, such as a `creating` hook that generates a UUID, and a fake without class names suppresses those events and can produce an invalid model. Call the fake first only when a factory event is under test, and pass that event's class name.
## The Mocks
Use `shouldReceive()` before the action to declare an expectation. Use `shouldHaveReceived()` after the action for a spy. Use `Mockery::on()` or `withArgs()` if an equality check cannot state the expected argument, such as a check of one field of a value object.
Use `$this->mock(Contract::class)` to put a mock in the container. Do not build a PHPUnit mock for a class that Mockery can double, because the project uses Mockery.
## The Outbound HTTP
Call `Http::preventStrayRequests()`. Any request without a matching fake then fails without reaching the network.
Fake the exact endpoint used by each test. Do not call `Http::fake()` without an endpoint because it accepts unexpected requests and can hide defects.
## The Time and the Randomness
- Freeze the time or move the time in each test that depends on a date, a period, or a timestamp.
- Use the framework helpers `$this->freezeTime()`, `$this->travelTo()`, `$this->travel()`, and `$this->travelBack()`. Do not call `Carbon::setTestNow()`.
- Use `Str::createRandomStringsUsing()` to fix a generated string, if the test asserts an identifier or a slug.
- Use `Sleep::fake()` instead of a real sleep, and assert the sleeps that the code requests.
- Restore the time and the randomness after each test, if the suite does not restore them for every test.
## The Database
- Run the real query against the real records in the test database. Do not mock the query builder, because the test then asserts the mock.
- Assert the exact keys of `toArray()` if the shape of the serialized model is a contract. The test then fails when the model exposes a new attribute.
- Test application behavior caused by the schema, such as deleting dependent records through a cascade. Do not test the database engine's cascade implementation.
- Use `LazilyRefreshDatabase` instead of `RefreshDatabase`. A test that does not use the database then does not run the migrations.
@@ -0,0 +1,37 @@
# Naming and Structure
## File Layout
- Name each test file `{ClassName}Test.php`.
- Place each test file at the same relative path as the class under test. The class `app/Actions/DeleteTeam.php` gets the test `tests/Unit/Actions/DeleteTeamTest.php`.
- Follow the project's convention for fixture files. If none exists, put fixtures in `tests/Fixtures/` and load them by path.
- Move large literal values out of the test body and into fixture files.
## The Test Class and the Test Methods
- Extend the base `TestCase` of the project in each test class.
- Give each test method the prefix `test_`, or add the `#[Test]` attribute to the method. Use the convention of the other files in the same directory.
## The Names of the Tests
The name of a test method is a specification. Separate the words with underscores. State the user-visible result and the condition that causes it.
- Name the behavior, and not the method under test. The file name already gives the class.
- Give the exact status code in the name of a test for an API error.
- Do not write `given`, `when`, or `then` in the name.
```php
public function test_unauthenticated_request_redirects_to_login(): void { ... }
public function test_returns_401_when_no_token_is_provided(): void { ... }
public function test_valid_payload_creates_record_and_returns_201(): void { ... }
```
Use a verb that describes a result, such as `returns`, `renders`, `creates`, `dispatches`, `rejects`, `forbids`, `falls back`, or `does not`.
Do not write `test_store()`, `test_it_works()`, or `test_validation()`, because none of them gives a result.
## Grouping
Write one test class for each class under test. Write a separate test class if one file covers separate actions in a lifecycle, such as `StoreOrderControllerTest` and `UpdateOrderControllerTest`.
Use the `#[Group]` attribute to mark the tests that a run must select or must skip. Do not use a group to give structure to a file, because a class gives the structure.
@@ -0,0 +1,46 @@
# Test Suite Performance
These settings apply to the project and CI, not to individual tests. Read `rules/isolation.md` for choices within a test.
Fetch `https://docs.phpunit.de/en/13.3/` for PHPUnit options that make test runs faster.
Verify each flag in the documentation before adding it to CI.
Measure before changing a setting. Find the slow test first, and apply a project-wide setting only after identifying the costly work.
## The Environment
- Set `BCRYPT_ROUNDS=4` in `.env.testing` or in `phpunit.xml`. The default value is 12, and the hash then takes most of the time of each test that signs a user in.
- Disable XDebug. Disable pcov also, unless the run needs the coverage.
- Disable packages that perform work on every request in the test environment. Examples are Pulse, Telescope, and Nightwatch.
- Use the `WithCachedConfig` and `WithCachedRoutes` traits, so the run does not parse the configuration and the routes for every test.
- Call `withoutVite()`, or `withoutMix()`, so the framework does not resolve a built asset.
## The Global Fakes
Put these three calls in the `setUp()` of the base `TestCase` of the project:
- `Http::preventStrayRequests()`, because one request that reaches the network can slow the suite. This catches requests made through Laravel's HTTP client. Check direct Guzzle and cURL usage separately.
- `Sleep::fake(syncWithCarbon: true)`, so a retry and a backoff do not sleep.
- `Exceptions::fake()`, so the suite does not report an exception to an external service.
## How to Run the Suite in Parallel
Run `php artisan test --parallel`, which uses ParaTest, to spread tests across the machine's CPU cores. Add `--processes=N` if the default count is unsuitable for the machine or CI.
A parallel run gives each process a separate database. Tests must meet these conditions; a test that fails only in parallel breaks one of them:
- The test creates each record that it reads. It does not read a record that another test creates.
- The test does not depend on the order of the run.
- The test does not share a file, a cache key, or a queue with another test. Give each process a separate name for such a resource.
## How to Find a Slow Test
Run `php artisan test --profile` to list the slowest tests. Start with the ten slowest tests, because the same cause often applies to the complete suite.
If the cause of a slow test is unclear, add an event listener or temporary log entry to identify its work.
## Common Errors
- The run loads XDebug for a test that does not need it.
- `BCRYPT_ROUNDS` keeps the default value, because the project has no `.env.testing`.
- The code under test calls the real `sleep()`, and `Sleep::fake()` then does not help.
@@ -0,0 +1,51 @@
# Reviewing Tests
Check every item in this file. A passing test may still provide no value. For each test, identify the defect it would catch.
Report each finding. Do not delete or rewrite a test without the user's approval. When an issue appears throughout the suite as a convention, report the pattern once rather than every affected file.
## The Value of the Test
- [ ] Each test covers observable behavior or an application contract, and passes after a change to the implementation that keeps the behavior.
- [ ] Each tested declaration is exercised through behavior, and no test asserts the behavior of the framework. A test of what this project configures, such as a relation with a constraint, a cast, or a scope, belongs to this project.
- [ ] Each test detects a distinct defect that no other test covers. A duplicate shrinks at the higher layer to the one case that proves the wiring.
- [ ] Every changed decision and each applicable high-value failure mode has coverage.
## Names and Structure
- [ ] Each file has the name `{ClassName}Test.php` and the relative path of the class under test.
- [ ] Each name states a result, the condition that causes it, and the status code for an API error.
- [ ] Each test class extends the base `TestCase` of the project, and each file uses either the prefix `test_` or the `#[Test]` attribute consistently.
## The Coverage
- [ ] HTTP tests cover authentication, authorization, role, scope, and validation when applicable.
- [ ] A request for a record of a different tenant gets a status code that does not confirm that the record exists.
- [ ] The complete permission matrix belongs in policy tests, not controller tests.
- [ ] Each validation rule has one test that asserts the user-visible message. When a unit test owns a matrix, reduce duplicate higher-level coverage to one case rather than deleting it.
- [ ] Rendered user input and each dynamic part of a query have a security test.
## The Data and the Determinism
- [ ] Each test creates its mutable records directly or through a helper that it calls, and every created record arranges the behavior or supports an assertion.
- [ ] `setUp()` holds configuration only.
- [ ] Each factory state and each relationship gives the meaning of the data.
- [ ] Each call to `make()` is in a test that does not need the database.
- [ ] Time, randomness, sleep, and outbound HTTP are controlled.
- [ ] Each test passes alone, and passes in the complete suite in any order.
## The Assertions
- [ ] Each expected value is a known value, and the test does not calculate the value with the logic of the implementation.
- [ ] Each test of a write operation asserts the response, the state in the database, and the side effects.
- [ ] Each fake has one assertion, and gives the class names unless the test asserts the complete result.
- [ ] Each group of assertions stays on one subject, and each comparison uses `assertSame()`.
## The Defects to Report
A review can find defects in the code rather than the tests. Report each defect below, and do not write a test that codifies it as correct behavior.
- [ ] A method with no body.
- [ ] A policy that exists, but that no action calls.
- [ ] A write action with no validation.
- [ ] A status code or a response shape that is different from the shape of a similar endpoint.
@@ -0,0 +1,27 @@
# Security Tests
Test each security boundary where user input affects authorization, rendered output, or query construction. A defect at such a boundary can be difficult to detect because the feature may continue to work.
Write a test for each of these cases:
- **Cross-tenant access.** Request a record of a different tenant, team, or organization. Read `rules/endpoint-tests.md` for why the response should be `404` rather than `403`.
- **Each unprivileged role.** Use a data provider over the roles that the endpoint must refuse.
- **Escaping user-provided content.** Test escaping in HTML and mail. Include names and every free-text field a template renders. Assert that dangerous characters are escaped and the raw value is absent. Do not assert an exact entity for a quote, because Markdown and mail CSS inliners may decode it.
- **Injection into dynamic query components.** Examples include sort columns, filter fields, and sort directions.
- **An unexpected key** in a payload or configuration array. A merge that accepts every key can set an attribute the user must not control.
```php
public function test_escapes_dangerous_content_in_the_notification(): void
{
$organization = Organization::factory()->make([
'name' => "O'Reilly <script>alert('xss')</script>",
]);
$content = (new QuotaApproaching($organization, 80))->toMail()->render();
$this->assertStringContainsString('<script>', $content);
$this->assertStringNotContainsString("<script>alert('xss')</script>", $content);
}
```
Laravel provides defenses against mass assignment, unauthorized access, and unescaped output. Test that the application applies the appropriate defense to each attribute, route, and template.
@@ -0,0 +1,68 @@
# Factories and Test Data
## Each Test Makes Its Own Data
Create mutable records inside each test or through a private helper that the test calls. This keeps setup visible and lets each test select its factory state.
Use `setUp()` only for configuration that applies to every test in the class. Do not create records in it, because its objects remain in memory until the suite ends.
## Record Construction
- Use `create()` if the test needs the record in the database.
- Use `make()` only if the test does not need the database. Examples include rendering a notification and testing a value object's behavior.
- Use a named factory state instead of a raw attribute. `User::factory()->unverified()->create()` gives the state meaning; `create(['email_verified_at' => null])` gives only its value.
- Use `for()` or the relationship helper of the project to declare the owner of a record.
- Use `recycle()` if several records must share one parent record.
- Use `sequence()` if several records need different attributes.
```php
$organization = Organization::factory()->onPlan(BillingPlan::PRO)->create();
$environment = Environment::factory()->recycle($organization)->create();
$organizations = Organization::factory()
->count(3)
->sequence(
['created_at' => now()->setSeconds(30)],
['created_at' => now()->setSeconds(1)],
)
->create();
```
Create only the records required to arrange the behavior or support an assertion.
## The Data Providers
Use a data provider when the setup, test body, and assertions remain the same across input values.
```php
public static function nonAdminRoles(): array
{
return collect(Role::cases())
->reject(fn (Role $role): bool => $role === Role::ADMIN)
->mapWithKeys(fn (Role $role): array => [$role->value => [$role]])
->all();
}
#[DataProvider('nonAdminRoles')]
public function test_forbids_roles_other_than_admin(Role $role): void
{
$this->actingAs(User::factory()->hasOrganization($role)->create())
->post('/settings')
->assertForbidden();
}
```
Declare each data provider method as `public static`.
Use parameterized tests for:
- the cases of an enum
- the roles and the plans
- the boundary values
- the input values that are not valid in the same way
- the pairs of an input value and an output value
Write separate tests if the cases need a different setup, a different behavior, or different assertions. One test function with a branch in the body is two tests in one function.
Give each data-provider case a key that states the difference. A failure then identifies the case without requiring you to count positions.