-
-
Notifications
You must be signed in to change notification settings - Fork 487
Expand file tree
/
Copy pathCachedTenantResolverTest.php
More file actions
465 lines (365 loc) · 19.4 KB
/
CachedTenantResolverTest.php
File metadata and controls
465 lines (365 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
<?php
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use Stancl\Tenancy\TenancyServiceProvider;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withCacheTables;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach($cleanup = function () {
Tenant::$extraCustomColumns = [];
TenancyServiceProvider::$adjustCacheManagerUsing = null;
});
afterEach($cleanup);
test('tenants can be resolved using cached resolvers', function (string $resolver, bool $configureTenantModelColumn) {
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
$tenant->createDomain($tenant->{$tenantModelColumn});
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
})->with([
DomainTenantResolver::class,
PathTenantResolver::class,
RequestDataTenantResolver::class,
])->with([
'tenant model column is id (default)' => false,
'tenant model column is name (custom)' => true,
]);
test('the underlying resolver is not touched when using the cached resolver', function (string $resolver, bool $configureTenantModelColumn) {
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
$tenant->createDomain($tenant->{$tenantModelColumn});
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => false]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
pest()->assertNotEmpty(DB::getQueryLog()); // not empty
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty(); // empty
})->with([
DomainTenantResolver::class,
PathTenantResolver::class,
RequestDataTenantResolver::class,
])->with([
'tenant model column is id (default)' => false,
'tenant model column is name (custom)' => true,
]);
test('cache is invalidated when the tenant is updated', function (string $resolver, bool $configureTenantModelColumn) {
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
$tenant->createDomain($tenant->{$tenantModelColumn});
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty();
// Tenant cache gets invalidated when the tenant is updated
$tenant->touch();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
})->with([
DomainTenantResolver::class,
PathTenantResolver::class,
RequestDataTenantResolver::class,
])->with([
'tenant model column is id (default)' => false,
'tenant model column is name (custom)' => true,
]);
// Only testing update here - presumably if this works, deletes (and other things we test here)
// will work as well. The main unique thing about this test is that it makes the change from
// *within* the tenant context.
test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheStore, array $bootstrappers) {
config([
'cache.default' => $cacheStore,
'tenancy.bootstrappers' => $bootstrappers,
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
if ($cacheStore === 'database') {
withCacheTables();
withTenantDatabases();
}
$resolver = PathTenantResolver::class;
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn(true) => 'acme']);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty();
DB::flushQueryLog();
// Different cache context
tenancy()->initialize($tenant);
// Tenant cache gets invalidated when the tenant is updated
$tenant->touch();
// If we use 'globalCache' in CachedTenantResolver's constructor (correct) this line has no effect - in either case the global cache is used.
// If we use 'cache' (regression) this line DOES have an effect. If we don't end tenancy, the tenant's cache is used, which
// wasn't populated before, so the test succeeds - a query is made. But if we do end tenancy, the central cache is used,
// which was populated BUT NOT INVALIDATED which makes the test fail.
//
// The actual resolver would only ever run in the central context, but this detail makes it easier to reason about how this test
// confirms the fix.
tenancy()->end();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
})->with([
['redis', [CacheTenancyBootstrapper::class]],
['redis', [CacheTagsBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]],
]);
test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) {
DB::statement('SET FOREIGN_KEY_CHECKS=0;'); // allow deleting the tenant
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
$tenant->createDomain($tenant->{$tenantModelColumn});
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty();
$tenant->delete();
DB::flushQueryLog();
expect(fn () => app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn)))->toThrow(TenantCouldNotBeIdentifiedException::class);
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
})->with([
DomainTenantResolver::class,
PathTenantResolver::class,
RequestDataTenantResolver::class,
])->with([
'tenant model column is id (default)' => false,
'tenant model column is name (custom)' => true,
]);
test('cache is invalidated when a tenants domain is changed', function () {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty(); // empty
$tenant->createDomain([
'domain' => 'bar',
]);
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
pest()->assertNotEmpty(DB::getQueryLog()); // not empty
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('bar')))->toBeTrue();
pest()->assertNotEmpty(DB::getQueryLog()); // not empty
});
test('cache is invalidated when a tenants domain is deleted', function () {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty(); // empty
$tenant->domains->first()->delete();
DB::flushQueryLog();
expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class);
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
});
test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
DB::enableQueryLog();
Tenant::create(['id' => 'foo']);
RouteFacade::get('/', fn () => request()->route()->parameter('tenant') ? 'Tenant parameter present' : 'No tenant parameter')
->name('tenant-route')
->prefix('{tenant}')
->middleware(InitializeTenancyByPath::class);
// Tenant gets cached on first request
pest()->get("/foo")->assertSee('No tenant parameter');
// Tenant is resolved from cache on second request
// The tenant parameter should be forgotten
DB::flushQueryLog();
pest()->get("/foo")->assertSee('No tenant parameter');
pest()->assertEmpty(DB::getQueryLog()); // resolved from cache
});
test('PathTenantResolver properly separates cache for each tenant column', function () {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
Tenant::$extraCustomColumns = ['slug'];
DB::enableQueryLog();
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
$t1 = Tenant::create(['id' => 'foo', 'slug' => 'bar']);
$t2 = Tenant::create(['id' => 'bar', 'slug' => 'foo']);
RouteFacade::get('x/{tenant}/a', function () {
return tenant()->getTenantKey();
})->middleware(InitializeTenancyByPath::class);
RouteFacade::get('x/{tenant:slug}/b', function () {
return tenant()->getTenantKey();
})->middleware(InitializeTenancyByPath::class);
DB::flushQueryLog();
$redisKeys = fn () => array_map(
fn (string $key) => str($key)->after('PathTenantResolver:')->toString(),
Redis::connection('cache')->keys('*')
);
pest()->get("/x/foo/a")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(1);
expect(DB::getRawQueryLog()[0]['raw_query'])->toBe("select * from `tenants` where `id` = 'foo' limit 1");
expect($redisKeys())->toEqualCanonicalizing([
'["id","foo"]',
]);
pest()->get("/x/bar/b")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(2);
expect(DB::getRawQueryLog()[1]['raw_query'])->toBe("select * from `tenants` where `slug` = 'bar' limit 1");
expect($redisKeys())->toEqualCanonicalizing([
'["id","foo"]',
'["slug","bar"]',
]);
// Test if cache hits
pest()->get("/x/foo/a")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(2); // unchanged
expect(count($redisKeys()))->toBe(2); // unchanged
pest()->get("/x/bar/b")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(2); // unchanged
expect(count($redisKeys()))->toBe(2); // unchanged
// Make requests for a tenant that has reversed values for the columns
pest()->get("/x/bar/a")->assertSee('bar');
expect(count(DB::getRawQueryLog()))->toBe(3); // +1
expect(DB::getRawQueryLog()[2]['raw_query'])->toBe("select * from `tenants` where `id` = 'bar' limit 1");
expect($redisKeys())->toEqualCanonicalizing([
'["id","foo"]',
'["slug","bar"]',
'["id","bar"]', // added
]);
pest()->get("/x/foo/b")->assertSee('bar');
expect(count(DB::getRawQueryLog()))->toBe(4);
expect(DB::getRawQueryLog()[3]['raw_query'])->toBe("select * from `tenants` where `slug` = 'foo' limit 1");
expect($redisKeys())->toEqualCanonicalizing([
'["id","foo"]',
'["slug","bar"]',
'["id","bar"]',
'["slug","foo"]', // added
]);
// Test if cache hits for the tenant with reversed values
pest()->get("/x/bar/a")->assertSee('bar');
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
expect(count($redisKeys()))->toBe(4); // unchanged
pest()->get("/x/foo/b")->assertSee('bar');
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
expect(count($redisKeys()))->toBe(4); // unchanged
// Try to resolve the previous tenant again, confirming the cache values for the new tenant are properly separated from the previous tenant
pest()->get("/x/foo/a")->assertSee('foo');
pest()->get("/x/foo/b")->assertSee('bar');
pest()->get("/x/bar/a")->assertSee('bar');
pest()->get("/x/bar/b")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
expect(count($redisKeys()))->toBe(4); // unchanged
$t1->update(['random_value' => 'just to clear cache']);
expect($redisKeys())->toEqualCanonicalizing([
// '["id","foo"]', // these two have been removed
// '["slug","bar"]',
'["id","bar"]',
'["slug","foo"]',
]);
$t2->update(['random_value' => 'just to clear cache']);
expect($redisKeys())->toBe([]);
DB::flushQueryLog();
// Cache gets repopulated
pest()->get("/x/foo/a")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(1);
expect(count($redisKeys()))->toBe(1);
pest()->get("/x/foo/b")->assertSee('bar');
expect(count(DB::getRawQueryLog()))->toBe(2);
expect(count($redisKeys()))->toBe(2);
pest()->get("/x/bar/a")->assertSee('bar');
expect(count(DB::getRawQueryLog()))->toBe(3);
expect(count($redisKeys()))->toBe(3);
pest()->get("/x/bar/b")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(4);
expect(count($redisKeys()))->toBe(4);
// After which, the cache becomes active again
pest()->get("/x/foo/a")->assertSee('foo');
pest()->get("/x/foo/b")->assertSee('bar');
pest()->get("/x/bar/a")->assertSee('bar');
pest()->get("/x/bar/b")->assertSee('foo');
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
expect(count($redisKeys()))->toBe(4); // unchanged
});
/**
* This method is used in generic tests to ensure that caching works correctly both with default and custom resolver config.
*
* If $configureTenantModelColumn is false, the tenant model column is 'id' (default) -- don't configure anything, keep the defaults.
* If $configureTenantModelColumn is true, the tenant model column should be 'name' (custom) -- configure tenant_model_column in the resolvers.
*/
function tenantModelColumn(bool $configureTenantModelColumn): string {
// Default tenant model column is 'id'
$tenantModelColumn = 'id';
if ($configureTenantModelColumn) {
// Use 'name' as the custom tenant model column
$tenantModelColumn = 'name';
Tenant::$extraCustomColumns = [$tenantModelColumn];
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$table->string($tenantModelColumn)->unique();
});
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
}
return $tenantModelColumn;
}
/**
* This method is used in generic tests where we test all the resolvers
* to make getting the resolver arguments less repetitive (primarily because of PathTenantResolver).
*
* For PathTenantResolver, return a route instance with the value retrieved using $tenant->{$parameterColumn} as the parameter.
* For RequestDataTenantResolver and DomainTenantResolver, return the value retrieved using $tenant->{$parameterColumn}.
*
* Tenant model column is 'id' by default, but in the generic tests,
* we also configure that to 'name' to ensure everything works both with default and custom config.
*/
function getResolverArgument(string $resolver, Tenant $tenant, string $parameterColumn = 'id'): string|Route
{
if ($resolver === PathTenantResolver::class) {
// PathTenantResolver uses a route instance as the argument
$routeName = 'tenant-route';
// Find or create a route instance for the resolver
$route = RouteFacade::getRoutes()->getByName($routeName) ?? RouteFacade::get('/', fn () => null)
->name($routeName)
->prefix('{tenant}')
->middleware(InitializeTenancyByPath::class);
/**
* To make the tenant available on the route instance,
* set the 'tenant' route parameter to the tenant model column value ('id' or 'name').
*
* Setting the parameter on the $route->parameters property is required
* because $route->setParameter() throws an exception when $route->parameters isn't set yet.
*/
$route->parameters['tenant'] = $tenant->{$parameterColumn};
// Return the route instance with 'id' or 'name' as the 'tenant' parameter
return $route;
}
// Assuming that:
// - with RequestDataTenantResolver, the tenant model column value is the payload value
// - with DomainTenantResolver, the tenant has a domain with name equal to the tenant model column value (see the createDomain() calls in various tests)
return $tenant->{$parameterColumn};
}