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>
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
/**
|
|
* O-series — Fase 2b.2 (offline-first)
|
|
*
|
|
* Uses Playwright's `context.setOffline(true)` to simulate a dropped
|
|
* connection, then verifies that:
|
|
* (a) mutations still work locally (optimistic UI + enqueue)
|
|
* (b) a banner tells the user they're offline
|
|
* (c) going back online flushes the queue and the items persist server-side
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import { USERS } from '../fixtures/users.js';
|
|
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,
|
|
context
|
|
}) => {
|
|
await loginAs(page, USERS.borja);
|
|
await page.goto(SEED_LIST_PATH);
|
|
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Go offline. The SyncBanner subscribes to `navigator.onLine` via the
|
|
// isOnline store and should render within a tick.
|
|
await context.setOffline(true);
|
|
await expect(page.getByTestId('sync-banner-offline')).toBeVisible({ timeout: 3_000 });
|
|
|
|
const itemName = `O-01-${Date.now()}`;
|
|
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
|
await input.fill(itemName);
|
|
await input.press('Enter');
|
|
|
|
// Item renders locally even though the server call failed.
|
|
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
|
|
|
|
// Cleanup: bring the page back online so the next test starts fresh.
|
|
await context.setOffline(false);
|
|
});
|
|
|
|
test('OF-01: going offline shows the persistent chip + a queue badge', async ({
|
|
page,
|
|
context
|
|
}) => {
|
|
await loginAs(page, USERS.borja);
|
|
await page.goto(SEED_LIST_PATH);
|
|
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Online → chip hidden.
|
|
await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0);
|
|
|
|
await context.setOffline(true);
|
|
// At least one chip (mobile + desktop both mount; only the visible one
|
|
// matters but locator-count-based assertions need both).
|
|
await expect(page.getByTestId('pwa-offline-chip').first()).toBeVisible({ timeout: 3_000 });
|
|
|
|
const itemName = `OF-01-${Date.now()}`;
|
|
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
|
await input.fill(itemName);
|
|
await input.press('Enter');
|
|
|
|
// pending_ops should now contain at least the INSERT for the new item.
|
|
await expect(page.getByTestId('pwa-offline-chip-badge').first()).toHaveText(/\d+/, {
|
|
timeout: 3_000
|
|
});
|
|
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
|
|
|
|
await context.setOffline(false);
|
|
await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0, { timeout: 5_000 });
|
|
});
|
|
|
|
test('O-02: going back online flushes the queue to the server', async ({
|
|
browser,
|
|
context
|
|
}) => {
|
|
const page = await context.newPage();
|
|
await loginAs(page, USERS.borja);
|
|
await page.goto(SEED_LIST_PATH);
|
|
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
|
|
|
await context.setOffline(true);
|
|
await expect(page.getByTestId('sync-banner-offline')).toBeVisible({ timeout: 3_000 });
|
|
|
|
const itemName = `O-02-${Date.now()}`;
|
|
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
|
await input.fill(itemName);
|
|
await input.press('Enter');
|
|
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
|
|
|
|
// Back online — the queue's 'online' listener flushes pending ops.
|
|
await context.setOffline(false);
|
|
await expect(page.getByTestId('sync-banner-offline')).not.toBeVisible({
|
|
timeout: 5_000
|
|
});
|
|
|
|
// Verify server-side persistence with a FRESH independent context:
|
|
// reload would use the same browser state; we want to prove the row
|
|
// actually made it to the DB by loading the list from a new session.
|
|
const verifyContext = await browser.newContext();
|
|
const verifyPage = await verifyContext.newPage();
|
|
await loginAs(verifyPage, USERS.ana);
|
|
await verifyPage.goto(SEED_LIST_PATH);
|
|
await expect(verifyPage.getByText(itemName)).toBeVisible({ timeout: 15_000 });
|
|
await verifyContext.close();
|
|
});
|
|
});
|