From 0a341e7521e20bf0397258795fdfe14d6e80e95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Moroz?= Date: Wed, 8 Jul 2026 21:58:30 +0200 Subject: [PATCH] feat: add pools for resolving groups of names by key A pool is a named list of names sharing one field (service), defined in a separate pools.yml. Given a pool and an asked service, the service resolves every member through the existing QueryService. - Domain: Pool (getField/getNames), PoolQueryRepository (findByName), and PoolService (QueryService injected). `whatAreTheNamesOf` returns one answer per member, index-aligned and string|array|null, mirroring QueryService::whatAreTheNamesOf so callers get 207 semantics. `whoseNamesAreThere` flattens those answers into a distinct list (arrays spread in, nulls and duplicates dropped). When the asked service is the pool's own field, its names are returned verbatim. - Infrastructure: PoolYamlFileRepository replicates YamlFileRepository's mtime-based caching, indexing pools by name. - API: GET /whose-name/pool (per-member, 200/207/404) and GET /whose-name/pool/names (flattened distinct, 200/404). Both GET and lenient like the single query endpoint. - Wiring: WHOSENAME_POOLS_YAML config + PoolQueryRepository binding. - Docs: README Pools section and an ADL for the endpoint status/shape. Tests cover the domain (per-member shape, null kept vs dropped, dedup, same-field shortcut), the repository (find, cache, missing file), an end-to-end integration, and both endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + README.md | 54 ++++++ app/Providers/AppServiceProvider.php | 3 + config/whosename.php | 3 +- ...6-07-08-pool-endpoints-status-and-shape.md | 76 +++++++++ domain/WhoseName/Pool.php | 35 ++++ domain/WhoseName/PoolQueryRepository.php | 12 ++ domain/WhoseName/PoolService.php | 60 +++++++ .../WhoseName/PoolYamlFileRepository.php | 98 +++++++++++ routes/api.php | 30 ++++ .../QueryWhoseNamePoolEndpoint.php | 104 +++++++++++ tests/Domain/AskPoolServiceForNames.php | 161 ++++++++++++++++++ ...PoolYamlFileRepositoryHandlesYamlFiles.php | 160 +++++++++++++++++ tests/pools.yml | 26 +++ 14 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 docs/adl/2026-07-08-pool-endpoints-status-and-shape.md create mode 100644 domain/WhoseName/Pool.php create mode 100644 domain/WhoseName/PoolQueryRepository.php create mode 100644 domain/WhoseName/PoolService.php create mode 100644 infrastructure/WhoseName/PoolYamlFileRepository.php create mode 100644 tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php create mode 100644 tests/Domain/AskPoolServiceForNames.php create mode 100644 tests/Infrastructure/PoolYamlFileRepositoryHandlesYamlFiles.php create mode 100644 tests/pools.yml diff --git a/.gitignore b/.gitignore index 029394d..7408662 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ yarn-error.log /.idea /.vscode whosename.ignored.yml +pools.ignored.yml .DS_Store diff --git a/README.md b/README.md index 5803758..31e8928 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,53 @@ found. The endpoint returns: - `422 Unprocessable Entity` when the request body is malformed (each query must provide non-empty `u`, `s` and `q`; a batch may contain between 1 and 100 queries). +### Pools + +A **pool** is a named list of names sharing a single field (service). Pools live in +their own file (`pools.yml` by default): + +```yml +- + name: Everyone + field: email + names: + - michal@makimo.pl + - alice@makimo.pl +``` + +Both pool endpoints take `p` (the pool name) and `q` (the service you ask about). + +**Per-member** — `GET /api/whose-name/pool` resolves every member on the asked +service and returns one `{"username": ...}` result per member, **in pool order** +(same shape and status semantics as the batch query — a member may resolve to a +string, an array of names, or `null`): + +``` +curl 'http://localhost/api/whose-name/pool?p=Everyone&q=jira' \ + -H "Accept: application/json" \ + -H "Authorization: Bearer " +[{"username":"jira1"},{"username":["jira2","jira3"]}] +``` + +- `200 OK` when every member resolved, +- `207 Multi-Status` when at least one member resolved to `null`, +- `404 Not Found` (with `[]`) when the pool is unknown or has no members. + +When `q` is the pool's own field, the pool's names are returned verbatim. + +**Flattened** — `GET /api/whose-name/pool/names` returns the same answers flattened +into a single, **distinct** list of names (arrays spread in, `null`s and duplicates +dropped): + +``` +curl 'http://localhost/api/whose-name/pool/names?p=Everyone&q=jira' \ + -H "Accept: application/json" \ + -H "Authorization: Bearer " +{"names":["jira1","jira2","jira3"]} +``` + +Returns `200 OK`, or `404 Not Found` (with `{"names":[]}`) when nothing was found. + ### Changing the Yaml file By default, the project uses the `tests/whosename.yml` file. The file contains two users and is not suited for more extensive work or running a working copy of the API. @@ -125,6 +172,13 @@ If you'd like to change the path, modify the following entry in your `.env` file WHOSENAME_YAML=tests/whosename.yml ``` +Pools are read from a separate file, configured the same way (defaults to +`tests/pools.yml`): + +``` +WHOSENAME_POOLS_YAML=tests/pools.yml +``` + Because of Docker containers, the file must be located inside the repository. ### Creating users and requesting tokens diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cbf63b2..f2591d3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,13 +4,16 @@ use Illuminate\Support\ServiceProvider; use Domain\WhoseName\IdentityQueryRepository; +use Domain\WhoseName\PoolQueryRepository; use Infrastructure\WhoseName\YamlFileRepository; +use Infrastructure\WhoseName\PoolYamlFileRepository; class AppServiceProvider extends ServiceProvider { public $bindings = [ IdentityQueryRepository::class => YamlFileRepository::class, + PoolQueryRepository::class => PoolYamlFileRepository::class, ]; /** diff --git a/config/whosename.php b/config/whosename.php index 3146f8f..c5468e3 100644 --- a/config/whosename.php +++ b/config/whosename.php @@ -1,5 +1,6 @@ env('WHOSENAME_YAML', 'tests/whosename.yml') + 'yaml_file' => env('WHOSENAME_YAML', 'tests/whosename.yml'), + 'pools_file' => env('WHOSENAME_POOLS_YAML', 'tests/pools.yml'), ]; \ No newline at end of file diff --git a/docs/adl/2026-07-08-pool-endpoints-status-and-shape.md b/docs/adl/2026-07-08-pool-endpoints-status-and-shape.md new file mode 100644 index 0000000..f64dd64 --- /dev/null +++ b/docs/adl/2026-07-08-pool-endpoints-status-and-shape.md @@ -0,0 +1,76 @@ +# Pool endpoints: a per-member view and a flattened view + +**Status:** _accepted_. + +## Context + +A pool is a named list of names sharing one field (see +`Domain\WhoseName\Pool`). `PoolService` exposes two ways to read it: + +- `whatAreTheNamesOf($pool, $service)` — one answer per member, index-aligned + with the pool's names (`string|array|null` each), mirroring + `QueryService::whatAreTheNamesOf`. +- `whoseNamesAreThere($pool, $service)` — those answers flattened into a single + distinct list of names. + +Both needed HTTP exposure. The single (`GET /query`) and batch +(`POST /query/batch`) endpoints already set two precedents that pull in +different directions: the single GET is **lenient** (missing params default to +`''`, an unresolved query is `404` with `{"username":null}`), while the batch +POST is **strict** (`422` on a malformed body, `207` on partial results). + +## Decision + +Two `GET` endpoints under the `whose-name` group, both taking `p` (pool name) +and `q` (asked service): + +- `GET /pool` returns the **per-member** view: a JSON array of `{"username": ...}` + in pool order, one per member — the same envelope and order-as-correlation-key + contract as the batch endpoint. Status: + - `200 OK` when every member resolved, + - `207 Multi-Status` when at least one member resolved to `null`, + - `404 Not Found` (body `[]`) when the pool is unknown or empty. +- `GET /pool/names` returns the **flattened** view: `{"names": [...]}`, a distinct + flat list. Status `200 OK`, or `404 Not Found` (body `{"names": []}`) when empty. + +Both endpoints are **lenient** like the single GET: `p` and `q` default to `''` +and simply resolve to an empty result (→ `404`) rather than `422`. Strict +`422`-style validation is reserved for the body-carrying batch endpoint. + +The route closures stay thin HTTP↔domain maps: `/pool` maps each answer to +`{"username": ...}` and picks `200`/`207` from the presence of `null` (reusing +the batch endpoint's `in_array(null, …, true)` check); `/pool/names` returns the +service's flat list as-is. All iteration, flattening, de-duplication and the +same-field shortcut live in `PoolService`. + +## Consequences + +- `/pool` is a drop-in parallel of the batch endpoint — a client that already + handles `{"username": string|array|null}` items and `200`/`207` needs no new + parsing; only the request shape (a pool name instead of an explicit query list) + differs. +- `404` on an empty result conflates "unknown pool" with "known pool, no members + resolved". This matches the single endpoint's "nothing found → 404" and keeps + `null`/`[]` a valid, non-error answer inside a `207`; distinguishing the two + cases would need an extra signal and is deferred until a client needs it. +- `/pool/names` drops `null`s and duplicates, so it cannot be correlated back to + members — that is the explicit trade of the flattened view. Use `/pool` when + per-member correlation or `207` semantics matter. +- Leniency means a typo in `p` or `q` yields `404`, not `422`. Consistent with the + single GET, at the cost of not flagging malformed input. + +## Alternatives + +- **One endpoint with a `flat=true` flag** — rejected: two response shapes + (`[{...}]` vs `{"names":[…]}`) and two status tables behind one URL is harder to + document and cache than two intent-named routes. +- **`POST /pool/batch`** for many pools at once — deferred; no need yet, and it + can be added later exactly like `query/batch` without disturbing these routes. +- **Strict `422` validation of `p`/`q`** — rejected for parity with the single + GET; can be tightened later if clients prefer explicit rejection. +- **`200` with `[]` for an unknown pool** — rejected: hides misses behind a + success, unlike the rest of the API. + +## Decision date + +> 2026-07-08 diff --git a/domain/WhoseName/Pool.php b/domain/WhoseName/Pool.php new file mode 100644 index 0000000..49cfd0d --- /dev/null +++ b/domain/WhoseName/Pool.php @@ -0,0 +1,35 @@ +field = $field; + $this->names = $names; + } + + public function getField(): string { + return $this->field; + } + + public function getNames(): array { + return $this->names; + } +} diff --git a/domain/WhoseName/PoolQueryRepository.php b/domain/WhoseName/PoolQueryRepository.php new file mode 100644 index 0000000..60cfed4 --- /dev/null +++ b/domain/WhoseName/PoolQueryRepository.php @@ -0,0 +1,12 @@ +pools = $pools; + $this->queryService = $queryService; + } + + /** + * Resolve each pool member's name(s) on a given service. + * + * Returns one response per member, index-aligned with the pool's names + * and mirroring QueryService::whatAreTheNamesOf, so callers can map the + * results one-to-one (e.g. onto a batch HTTP response with 207 + * semantics). Each response is string|array|null, exactly as + * QueryService::whatIsTheNameOf returns it. When the asked service is the + * pool's own field, the pool's names are returned verbatim. + * + * @return array A list of string|array|null answers, one per member. + */ + public function whatAreTheNamesOf(string $poolName, string $askedService): array { + $pool = $this->pools->findByName($poolName); + + if ($pool->getField() === $askedService) { + return $pool->getNames(); + } + + return array_map( + fn ($name) => $this->queryService->whatIsTheNameOf( + $name, + $pool->getField(), + $askedService + ), + $pool->getNames() + ); + } + + /** + * The flat list of distinct names present in a pool on a given service. + * + * Flattens whatAreTheNamesOf: a member resolving to several names is + * spread in, a member resolving to none (null) is dropped, and any name + * reached through more than one member appears only once. Order follows + * first occurrence. + * + * @return array A flat list of unique names. + */ + public function whoseNamesAreThere(string $poolName, string $askedService): array { + return array_values(array_unique( + array_merge([], ...array_map( + fn ($response) => (array) $response, + $this->whatAreTheNamesOf($poolName, $askedService) + )) + )); + } +} diff --git a/infrastructure/WhoseName/PoolYamlFileRepository.php b/infrastructure/WhoseName/PoolYamlFileRepository.php new file mode 100644 index 0000000..a314570 --- /dev/null +++ b/infrastructure/WhoseName/PoolYamlFileRepository.php @@ -0,0 +1,98 @@ + $pool) { + $mapping[$pool['name']] = $index; + } + + return $mapping; + } + + public function __construct(?string $path = null) { + if(!$path) { + $path = config('whosename.pools_file'); + } + + if(str_starts_with($path, '/')) { + $this->sourceFilePath = $path; + } else { + $this->sourceFilePath = base_path($path); + } + + $this->prefix = hash("crc32b", $this->sourceFilePath); + } + + protected function load(): void { + $modificationTime = filemtime($this->sourceFilePath); + + if(!$modificationTime) { + throw new \RuntimeException("Yaml file not found!"); + } + + $cachedModificationTime = Cache::get($this->cacheKey('timestamp'), -1); + + $sourceFileNeedsReload = + $modificationTime > $cachedModificationTime + || !Cache::has($this->cacheKey('pools')); + + if($sourceFileNeedsReload) { + $this->poolMap = static::loadYamlFile($this->sourceFilePath); + $this->poolLookupMap = static::transformPoolListToIndex( + $this->poolMap + ); + + Cache::put($this->cacheKey('pools'), [ + $this->poolMap, + $this->poolLookupMap, + ]); + + Cache::put($this->cacheKey('timestamp'), $modificationTime); + + return; + } + + list( + $this->poolMap, + $this->poolLookupMap + ) = Cache::get($this->cacheKey('pools')); + } + + protected function cacheKey($property) { + return "whosename.pools.$this->prefix.$property"; + } + + public function findByName(string $name): Pool { + if(!$this->poolMap) { + $this->load(); + } + + if(!isset($this->poolLookupMap[$name])) { + return new Pool('', []); + } + + $pool = $this->poolMap[$this->poolLookupMap[$name]]; + + return new Pool($pool['field'], $pool['names']); + } + +} diff --git a/routes/api.php b/routes/api.php index ca4cd41..586861e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Auth; use Domain\WhoseName\QueryService; +use Domain\WhoseName\PoolService; /* |-------------------------------------------------------------------------- @@ -99,4 +100,33 @@ return response()->json($results, $allResolved ? 200 : 207); }); + + Route::get('/pool', function (Request $request, PoolService $service) { + $responses = $service->whatAreTheNamesOf( + $request->input('p', ''), + $request->input('q', '') + ); + + $results = array_map(fn ($username) => ['username' => $username], $responses); + + if (empty($results)) { + return response()->json($results, 404); + } + + $allResolved = !in_array(null, $responses, true); + + return response()->json($results, $allResolved ? 200 : 207); + }); + + Route::get('/pool/names', function (Request $request, PoolService $service) { + $names = $service->whoseNamesAreThere( + $request->input('p', ''), + $request->input('q', '') + ); + + return response()->json( + ['names' => $names], + empty($names) ? 404 : 200 + ); + }); }); diff --git a/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php b/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php new file mode 100644 index 0000000..d53dae3 --- /dev/null +++ b/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php @@ -0,0 +1,104 @@ +create(), ['whose-name']); + + // Slackers = [U123456, U234567] on slack, asked email: + // U123456 -> single@example.org (one name) + // U234567 -> [other@example.org, new@example.org] (several) + $response = $this->get('/api/whose-name/pool?p=Slackers&q=email'); + + $response->assertStatus(200); + $response->assertExactJson([ + ['username' => 'single@example.org'], + ['username' => ['other@example.org', 'new@example.org']], + ]); +}); + + +gest('usage', 'Querying a pool for its own field returns its names verbatim', function () { + Sanctum::actingAs(User::factory()->create(), ['whose-name']); + + $response = $this->get('/api/whose-name/pool?p=Slackers&q=slack'); + + $response->assertStatus(200); + $response->assertExactJson([ + ['username' => 'U123456'], + ['username' => 'U234567'], + ]); +}); + + +gest('edge', 'A pool with a member that does not resolve returns 207 with a null in place', function () { + Sanctum::actingAs(User::factory()->create(), ['whose-name']); + + // Mixed = [single@example.org, ghost@example.org] on email, asked jira: + // single@example.org -> test@example.org + // ghost@example.org -> null (unknown) + $response = $this->get('/api/whose-name/pool?p=Mixed&q=jira'); + + $response->assertStatus(207); + $response->assertExactJson([ + ['username' => 'test@example.org'], + ['username' => null], + ]); +}); + + +gest('edge', 'Querying an unknown pool returns 404 with an empty array', function () { + Sanctum::actingAs(User::factory()->create(), ['whose-name']); + + $response = $this->get('/api/whose-name/pool?p=Nobody&q=jira'); + + $response->assertStatus(404); + $response->assertExactJson([]); +}); + + +// --- GET /pool/names : flattened, distinct list of names, 200/404 --- + +gest('usage', 'Querying a pool\'s names returns a flat, distinct list', function () { + Sanctum::actingAs(User::factory()->create(), ['whose-name']); + + $response = $this->get('/api/whose-name/pool/names?p=Slackers&q=email'); + + $response->assertStatus(200); + $response->assertExactJson([ + 'names' => ['single@example.org', 'other@example.org', 'new@example.org'], + ]); +}); + + +gest('edge', 'A name reached through several members appears once', function () { + Sanctum::actingAs(User::factory()->create(), ['whose-name']); + + // Aliases = [other@example.org, new@example.org] both belong to the same + // identity, so both resolve to its single jira name. + $response = $this->get('/api/whose-name/pool/names?p=Aliases&q=jira'); + + $response->assertStatus(200); + $response->assertExactJson([ + 'names' => ['other@example.org'], + ]); +}); + + +gest('edge', 'Querying an unknown pool\'s names returns 404 with an empty list', function () { + Sanctum::actingAs(User::factory()->create(), ['whose-name']); + + $response = $this->get('/api/whose-name/pool/names?p=Nobody&q=jira'); + + $response->assertStatus(404); + $response->assertExactJson(['names' => []]); +}); diff --git a/tests/Domain/AskPoolServiceForNames.php b/tests/Domain/AskPoolServiceForNames.php new file mode 100644 index 0000000..318818c --- /dev/null +++ b/tests/Domain/AskPoolServiceForNames.php @@ -0,0 +1,161 @@ +shouldReceive('findByName') + ->with('Everyone') + ->andReturn(new Pool('email', ['michal@makimo.pl', 'alice@makimo.pl'])); + + // One member resolves to a single name, the other to several. + $query->shouldReceive('whatIsTheNameOf') + ->with('michal@makimo.pl', 'email', 'jira') + ->andReturn('jira1'); + $query->shouldReceive('whatIsTheNameOf') + ->with('alice@makimo.pl', 'email', 'jira') + ->andReturn(['jira2', 'jira3']); + + // Index-aligned with the members, mirroring QueryService::whatAreTheNamesOf. + expect($service->whatAreTheNamesOf('Everyone', 'jira')) + ->toEqual(['jira1', ['jira2', 'jira3']]); +}); + + +gest('edge', 'A member that does not resolve keeps its place as null', function () { + $pools = Mockery::mock(PoolQueryRepository::class); + $query = Mockery::mock(QueryService::class); + + $service = new PoolService($pools, $query); + + $pools->shouldReceive('findByName') + ->with('Everyone') + ->andReturn(new Pool('email', ['michal@makimo.pl', 'ghost@makimo.pl'])); + + $query->shouldReceive('whatIsTheNameOf') + ->with('michal@makimo.pl', 'email', 'jira') + ->andReturn('jira1'); + $query->shouldReceive('whatIsTheNameOf') + ->with('ghost@makimo.pl', 'email', 'jira') + ->andReturn(null); + + // null is kept in place so callers can map results and return 207, + // exactly like the batch query endpoint. + expect($service->whatAreTheNamesOf('Everyone', 'jira')) + ->toEqual(['jira1', null]); +}); + + +gest('usage', 'Querying a pool for its own field returns the pool\'s names unchanged', function () { + $pools = Mockery::mock(PoolQueryRepository::class); + $query = Mockery::mock(QueryService::class); + + $service = new PoolService($pools, $query); + + $pools->shouldReceive('findByName') + ->with('Everyone') + ->andReturn(new Pool('email', ['michal@makimo.pl', 'alice@makimo.pl'])); + + // Asking for the pool's own field is a shortcut; no resolution happens. + $query->shouldNotReceive('whatIsTheNameOf'); + + expect($service->whatAreTheNamesOf('Everyone', 'email')) + ->toEqual(['michal@makimo.pl', 'alice@makimo.pl']); +}); + + +gest('edge', 'An unknown (empty) pool yields an empty list from both methods', function () { + $pools = Mockery::mock(PoolQueryRepository::class); + $query = Mockery::mock(QueryService::class); + + $service = new PoolService($pools, $query); + + $pools->shouldReceive('findByName') + ->with('Nobody') + ->andReturn(new Pool('', [])); + + $query->shouldNotReceive('whatIsTheNameOf'); + + expect($service->whatAreTheNamesOf('Nobody', 'jira'))->toEqual([]); + expect($service->whoseNamesAreThere('Nobody', 'jira'))->toEqual([]); +}); + + +// --- whoseNamesAreThere: whatAreTheNamesOf flattened, nulls dropped --- + +gest('usage', 'whoseNamesAreThere flattens the per-member responses into one list', function () { + $pools = Mockery::mock(PoolQueryRepository::class); + $query = Mockery::mock(QueryService::class); + + $service = new PoolService($pools, $query); + + $pools->shouldReceive('findByName') + ->with('Everyone') + ->andReturn(new Pool('email', ['michal@makimo.pl', 'alice@makimo.pl'])); + + $query->shouldReceive('whatIsTheNameOf') + ->with('michal@makimo.pl', 'email', 'jira') + ->andReturn('jira1'); + $query->shouldReceive('whatIsTheNameOf') + ->with('alice@makimo.pl', 'email', 'jira') + ->andReturn(['jira2', 'jira3']); + + expect($service->whoseNamesAreThere('Everyone', 'jira')) + ->toEqual(['jira1', 'jira2', 'jira3']); +}); + + +gest('edge', 'whoseNamesAreThere keeps each name once, even when several members share it', function () { + $pools = Mockery::mock(PoolQueryRepository::class); + $query = Mockery::mock(QueryService::class); + + $service = new PoolService($pools, $query); + + $pools->shouldReceive('findByName') + ->with('Everyone') + ->andReturn(new Pool('email', ['michal@makimo.pl', 'alice@makimo.pl'])); + + // Both members resolve (partly) to the same name. + $query->shouldReceive('whatIsTheNameOf') + ->with('michal@makimo.pl', 'email', 'jira') + ->andReturn(['shared', 'jira1']); + $query->shouldReceive('whatIsTheNameOf') + ->with('alice@makimo.pl', 'email', 'jira') + ->andReturn(['shared', 'jira2']); + + // Deduplicated, in first-occurrence order. + expect($service->whoseNamesAreThere('Everyone', 'jira')) + ->toEqual(['shared', 'jira1', 'jira2']); +}); + + +gest('edge', 'whoseNamesAreThere drops members that do not resolve', function () { + $pools = Mockery::mock(PoolQueryRepository::class); + $query = Mockery::mock(QueryService::class); + + $service = new PoolService($pools, $query); + + $pools->shouldReceive('findByName') + ->with('Everyone') + ->andReturn(new Pool('email', ['michal@makimo.pl', 'ghost@makimo.pl'])); + + $query->shouldReceive('whatIsTheNameOf') + ->with('michal@makimo.pl', 'email', 'jira') + ->andReturn('jira1'); + $query->shouldReceive('whatIsTheNameOf') + ->with('ghost@makimo.pl', 'email', 'jira') + ->andReturn(null); + + expect($service->whoseNamesAreThere('Everyone', 'jira')) + ->toEqual(['jira1']); +}); diff --git a/tests/Infrastructure/PoolYamlFileRepositoryHandlesYamlFiles.php b/tests/Infrastructure/PoolYamlFileRepositoryHandlesYamlFiles.php new file mode 100644 index 0000000..b81e451 --- /dev/null +++ b/tests/Infrastructure/PoolYamlFileRepositoryHandlesYamlFiles.php @@ -0,0 +1,160 @@ +file = __DIR__ . '/../pools.yml'; +}); + + +gest('usage', 'Given a name, a matching Pool can be found', function () { + $repo = new PoolYamlFileRepository($this->file); + + $value = $repo->findByName('Everyone'); + + expect($value)->toBeInstanceOf(Pool::class); + expect($value->getField())->toEqual('email'); + expect($value->getNames())->toEqual(['single@example.org', 'other@example.org']); +}); + + +gest('edge', 'If there\'s no matching name, an empty Pool is returned', function () { + $repo = new PoolYamlFileRepository($this->file); + + $value = $repo->findByName(''); + + expect($value)->toBeInstanceOf(Pool::class); + expect($value->getField())->toEqual(''); + expect($value->getNames())->toEqual([]); +}); + + +gest('behavior', 'Loaded Yaml file persists in the cache', function () { + Cache::flush(); + + $copiedFile = __DIR__ . '/../pools.ignored.yml'; + + // Arrange: Copy the file and set its modified and access time in the past + copy($this->file, $copiedFile); + + $lastSecond = time() - 1; + + touch($copiedFile, $lastSecond, $lastSecond); + + clearstatcache(); + + // Assert the access time and modification time was set + // It hypothetically could fail on some strange file systems. + expect(filemtime($copiedFile)) + ->toEqual(fileatime($copiedFile)) + ->toEqual($lastSecond); + + // Act: With cache emptied, first access will read the file + $repo = new PoolYamlFileRepository($copiedFile); + $repo->findByName('Everyone'); + + clearstatcache(); + + // Assert: the file was accessed so the times don't match anymore + expect(filemtime($copiedFile)) + ->toBeLessThan(fileatime($copiedFile)); + + // Arrange: set times on the file in the past + touch($copiedFile, $lastSecond, $lastSecond); + + // Act: With cache set by previous repo call + // second access doesn't read the file + $anotherRepo = new PoolYamlFileRepository($copiedFile); + $anotherRepo->findByName('Everyone'); + + clearstatcache(); + + // Assert: The atime did not change, because + // the file was not read the second time + expect(filemtime($copiedFile)) + ->toEqual(fileatime($copiedFile)); +})->skip(function() { + $copiedFile = __DIR__ . '/../pools.ignored.yml'; + + copy($this->file, $copiedFile); + + return !hasFileAtimeChangedOnRead($copiedFile); +}, 'Skipped; filesystem does not support updating access time on read.'); + + +gest('behavior', 'Modifying Yaml file updates the cache', function () { + Cache::flush(); + + $copiedFile = __DIR__ . '/../pools.ignored.yml'; + + copy($this->file, $copiedFile); + + // Query the repository + $oldRepo = new PoolYamlFileRepository($copiedFile); + $oldPool = $oldRepo->findByName('Everyone'); + + // Update file and change it's modification time + $stat = stat($copiedFile); + + $oldContents = file_get_contents($copiedFile); + $replaced = str_replace('single@example.org', 'changed@example.org', $oldContents); + file_put_contents($copiedFile, $replaced); + + touch($copiedFile, $stat['mtime'] + 1); + clearstatcache(); + + // Query the repository once more + $newRepo = new PoolYamlFileRepository($copiedFile); + $newPool = $newRepo->findByName('Everyone'); + + expect($oldPool)->toBeInstanceOf(Pool::class); + expect($oldPool->getNames())->toEqual(['single@example.org', 'other@example.org']); + + expect($newPool)->toBeInstanceOf(Pool::class); + expect($newPool->getNames())->toEqual(['changed@example.org', 'other@example.org']); +}); + + +gest('edge', 'If the file does not exist, a query throws an exception', function () { + $repo = new PoolYamlFileRepository(__DIR__ . '/itdoesnotexist.yml'); + + $repo->findByName('Everyone'); + +})->throws(Exception::class); + + +gest('usage', 'A pool resolves real identities into per-member responses, flattened on demand', function () { + $query = new QueryService(new YamlFileRepository(__DIR__ . '/../whosename.yml')); + $pools = new PoolYamlFileRepository($this->file); + + $service = new PoolService($pools, $query); + + // Slackers = [U123456, U234567] on slack: + // U123456 -> email single@example.org (one name) + // U234567 -> email [other@example.org, new@example.org] (several) + // whatAreTheNamesOf keeps one response per member, shape preserved... + expect($service->whatAreTheNamesOf('Slackers', 'email'))->toEqual([ + 'single@example.org', + ['other@example.org', 'new@example.org'], + ]); + + // ...whoseNamesAreThere flattens them into a single list. + expect($service->whoseNamesAreThere('Slackers', 'email'))->toEqual([ + 'single@example.org', + 'other@example.org', + 'new@example.org', + ]); + + // Asking for the pool's own field short-circuits to its names verbatim. + expect($service->whatAreTheNamesOf('Slackers', 'slack')) + ->toEqual(['U123456', 'U234567']); + expect($service->whoseNamesAreThere('Slackers', 'slack')) + ->toEqual(['U123456', 'U234567']); +}); diff --git a/tests/pools.yml b/tests/pools.yml new file mode 100644 index 0000000..8035926 --- /dev/null +++ b/tests/pools.yml @@ -0,0 +1,26 @@ +# An example pools file, used by the testsuite. +# Names line up with the identities in whosename.yml. +- + name: Everyone + field: email + names: + - single@example.org + - other@example.org +- + name: Slackers + field: slack + names: + - U123456 + - U234567 +- + name: Mixed + field: email + names: + - single@example.org + - ghost@example.org +- + name: Aliases + field: email + names: + - other@example.org + - new@example.org