feat(fase-14): sync_conflicts review route + chrome banner (14.2.3)

New page `/settings/sync-conflicts` that lists the user's last 100
sync_conflicts rows side-by-side (local vs remote payloads) with two
dismiss actions: "Discard local" and "Discard remote". Both actions
write the row id into a `syncConflictsDismissed` localStorage set —
migration 016 made sync_conflicts append-only at the RLS layer, and
Fase 14 ships no DB migration, so dismissal is per-device and the
underlying log is preserved for operator audits.

`(app)/+layout.svelte` now probes sync_conflicts once per session
(deferred out of the auth callback for the same lock-deadlock reason
the collectives query is) and feeds the count into a new
`unresolvedConflictsCount` store. When non-zero AND the user isn't
already on the review route, an amber pill banner offers a one-tap
"Review" link to the page.

OF-02 e2e plants a sync_conflicts row through the dev-only `__sb`
window client, reloads, asserts the banner, clicks through to the
route, discards the row, and asserts the empty state. Existing rows
are pre-dismissed via localStorage so the test is deterministic
regardless of accumulated history (we can't DELETE from the table).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 07:06:24 +02:00
parent c8c7f9f8c9
commit 45cca5072b
4 changed files with 271 additions and 0 deletions

View File

@@ -14,6 +14,68 @@ import { loginAs } from '../fixtures/login.js';
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
test.describe('Sync conflicts review', () => {
test('OF-02: a planted sync_conflicts row surfaces the banner + lists on /settings/sync-conflicts, and Discard removes the row', async ({
page
}) => {
await loginAs(page, USERS.borja);
await page.goto('/lists');
// Wait for the layout's conflict probe to run + complete (it's
// deferred via setTimeout(0)).
await page.waitForFunction(
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
null,
{ timeout: 10_000 }
);
// sync_conflicts is append-only at the RLS layer (migration 016 has
// no DELETE policy). Instead of deleting, we pre-dismiss every
// existing row in localStorage so the route + banner reflect only
// the row we plant in this test.
await page.evaluate(async () => {
const sb = (window as unknown as {
__sb: {
auth: { getUser: () => Promise<{ data: { user: { id: string } | null } }> };
from: (t: string) => {
select: (c: string) => { eq: (k: string, v: string) => Promise<{ data: { id: string }[] | null }> };
insert: (row: unknown) => Promise<unknown>;
};
};
}).__sb;
const u = (await sb.auth.getUser()).data.user;
if (!u) throw new Error('no user');
const existing = (await sb.from('sync_conflicts').select('id').eq('user_id', u.id)).data ?? [];
localStorage.setItem('syncConflictsDismissed', JSON.stringify(existing.map((r) => r.id)));
await sb.from('sync_conflicts').insert({
user_id: u.id,
collective_id: null,
entity_type: 'shopping_item',
entity_id: '00000000-0000-0000-0000-000000000000',
local_version: { name: 'OF-02-local' },
remote_version: { name: 'OF-02-remote' },
resolution: 'remote_won'
});
});
// Reload to re-trigger the probe with the planted row in place.
await page.reload();
await expect(page.getByTestId('sync-conflicts-banner')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('sync-conflicts-banner').click();
await expect(page).toHaveURL(/\/settings\/sync-conflicts$/);
const row = page.getByTestId('sync-conflicts-route-row').first();
await expect(row).toBeVisible({ timeout: 5_000 });
await row.getByTestId('sync-conflicts-discard-local').click();
await expect(page.getByTestId('sync-conflicts-route-empty')).toBeVisible({ timeout: 3_000 });
// Tests can't DELETE from sync_conflicts (RLS append-only) — the
// row stays in the table. Dismissal lives in localStorage so the
// next run's pre-dismissal pass keeps it out of view.
});
});
test.describe('Offline queue + reconnect flush', () => {
test('O-01: going offline shows the banner and optimistic adds stay visible', async ({
page,