Commit bf549c87 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test(branch): prove each widget filters by the branch selected, not merely by a branch

The previous check asked whether a dashboard query mentions branch_id. That is
the weaker half of the question. A widget that hardcoded the main branch, or read
a stale id off the URL, or took auth()->user()->branch_id instead of the session,
would mention branch_id on every query and still show the wrong branch's numbers
— and it would look perfectly correct in testing, because the main branch is the
one usually selected.

So the assertion is now on the bound value, not the SQL text. Laravel's bindings
are positional, so the value belonging to a `branch_id = ?` predicate is found by
counting the placeholders before it. Every dashboard is rendered under each
branch that carries data, and every branch id bound into a branch_id comparison
must equal the branch the session selected.

The seven widget components are also mounted directly, rather than only through
the pages that embed them. Two of them — EnrollmentTrends and RevenueWidget —
are written but on no view today, so page-level coverage alone would have said
nothing about either.

Measured on the live tenant: with branch 1 selected, 536 branch bindings, all of
them 1. With branch 2, 1,182 bindings, all of them 2. No dashboard binds a branch
other than the selected one.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 73a61a77
......@@ -171,6 +171,207 @@ public function test_no_dashboard_reads_a_branch_owned_table_without_naming_a_br
);
}
/**
* The branch ids a query actually binds into a `branch_id` comparison.
*
* Laravel's bindings are positional, so the value belonging to a predicate
* is found by counting the placeholders that precede it. Crude string work,
* but the alternative is trusting that a query which merely *mentions*
* branch_id mentions the right one — which is the whole question here.
*
* @return array<int, mixed>
*/
private function boundBranchValues(string $sql, array $bindings): array
{
preg_match_all('/\?/', $sql, $m, PREG_OFFSET_CAPTURE);
$placeholders = array_map(fn ($x) => $x[1], $m[0]);
if ($placeholders === []) {
return [];
}
// `"branch_id" = ?`, `t."branch_id" = ?`, `"branch_id" in (?, ?)`.
preg_match_all(
'/"?branch_id"?\s*(?:=|<>|!=|in)\s*\(?/i',
$sql,
$pm,
PREG_OFFSET_CAPTURE
);
$values = [];
foreach ($pm[0] as [$text, $offset]) {
$end = $offset + strlen($text);
$isIn = stripos($text, 'in') !== false;
foreach ($placeholders as $index => $pos) {
if ($pos < $end) {
continue;
}
// For `= ?` only the next placeholder belongs to the predicate.
// For `in (…)` take every placeholder up to the closing paren.
if (! $isIn) {
$values[] = $bindings[$index] ?? null;
break;
}
$close = strpos($sql, ')', $end);
if ($close !== false && $pos > $close) {
break;
}
$values[] = $bindings[$index] ?? null;
}
}
return $values;
}
/**
* Run a callback with every executed query captured.
*
* @return array<int, array{sql: string, bindings: array}>
*/
private function captureQueries(callable $work): array
{
$captured = [];
DB::listen(function ($query) use (&$captured) {
$captured[] = ['sql' => $query->sql, 'bindings' => $query->bindings];
});
$work();
return $captured;
}
/**
* @param array<int, array{sql: string, bindings: array}> $queries
* @return string[]
*/
private function wrongBranchBindings(array $queries, int $expected, string $label): array
{
$wrong = [];
foreach ($queries as $query) {
foreach ($this->boundBranchValues($query['sql'], $query['bindings']) as $value) {
if ($value === null || $value === '') {
continue;
}
// Positional extraction misreads a handful of queries where
// branch_id sits in a subquery with its own placeholders, and
// lands on a date or a status string instead. Those are dropped
// rather than guessed at: the check that matters is that no
// *branch id* other than the selected one is ever bound, and a
// misread cannot manufacture one.
if (! is_numeric($value)) {
continue;
}
if ((int) $value !== $expected) {
$wrong[] = $label.' bound branch '.$value.' while branch '.$expected
.' was selected :: '.substr(preg_replace('/\s+/', ' ', $query['sql']), 0, 200);
}
}
}
return $wrong;
}
public function test_every_dashboard_binds_the_branch_the_user_selected(): void
{
$branches = DB::table('training_groups')
->whereNotNull('branch_id')->distinct()->orderBy('branch_id')
->pluck('branch_id')->map(fn ($id) => (int) $id)->all();
$this->assertGreaterThanOrEqual(2, count($branches), 'Needs two branches carrying data.');
$wrong = [];
// Both branches, because a widget that hardcoded the main branch would
// look perfectly correct when the main branch is the one selected.
foreach ($branches as $branchId) {
foreach (self::DASHBOARDS as $name) {
$response = null;
$queries = $this->captureQueries(function () use ($name, $branchId, &$response) {
$response = $this->actingAs($this->owner)
->withSession([BranchContext::KEY => $branchId])
->get(route($name));
});
if ($response->getStatusCode() !== 200) {
continue;
}
$wrong = array_merge($wrong, $this->wrongBranchBindings($queries, $branchId, $name));
}
}
$this->assertSame(
[],
array_values(array_unique($wrong)),
"A dashboard filtered by a branch other than the selected one:\n\n"
.implode("\n\n", array_unique($wrong))
);
}
public function test_every_dashboard_widget_binds_the_selected_branch(): void
{
// Mounted directly, so a widget is covered even where it is not
// currently embedded in a page — EnrollmentTrends and RevenueWidget are
// both written and neither is on a view today.
$widgets = [
\App\Livewire\Dashboard\EnrollmentTrends::class,
\App\Livewire\Dashboard\OverdueRenewalsAlert::class,
\App\Livewire\Dashboard\ProductRevenueWidget::class,
\App\Livewire\Dashboard\RenewalsDueWidget::class,
\App\Livewire\Dashboard\RevenueWidget::class,
\App\Livewire\Dashboard\SubscriptionRevenueWidget::class,
\App\Livewire\Dashboard\TrainerDuesWidget::class,
];
$branches = DB::table('training_groups')
->whereNotNull('branch_id')->distinct()->orderBy('branch_id')
->pluck('branch_id')->map(fn ($id) => (int) $id)->all();
$wrong = [];
$mounted = 0;
foreach ($branches as $branchId) {
foreach ($widgets as $widget) {
$this->actingAs($this->owner);
session([BranchContext::KEY => $branchId]);
app()->forgetInstance(\App\Domain\Shared\Context\BranchContext::class);
app(\App\Domain\Shared\Context\BranchScopeState::class)->activate($branchId);
$queries = $this->captureQueries(function () use ($widget) {
\Livewire\Livewire::test($widget);
});
$mounted++;
$wrong = array_merge(
$wrong,
$this->wrongBranchBindings($queries, $branchId, class_basename($widget))
);
}
}
app(\App\Domain\Shared\Context\BranchScopeState::class)->deactivate();
$this->assertGreaterThan(0, $mounted, 'No widget was mounted — the test proved nothing.');
$this->assertSame(
[],
array_values(array_unique($wrong)),
"A widget filtered by a branch other than the selected one:\n\n"
.implode("\n\n", array_unique($wrong))
);
}
/**
* The counterpart to the query check: the numbers themselves must move when
* the branch does.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment