/lists/[id]/+page.svelte now passes the current item names through to fetchSuggestions as excludeNames, both on initial cold-load and on every debounced keystroke. The exclude list is a `$derived` of `items` so it recomputes the moment an optimistic insert lands — the next keystroke no longer surfaces the freshly-added item. ItemSuggestions.svelte gets a stable data-testid handle on the strip plus per-chip testids so the suite can assert presence/absence without fragile text matches. The component still hides itself entirely when the post-filter suggestion list is empty (the dropdown was already gated on `suggestions.length > 0`). Playwright CI-02 lives on a freshly-created list (the seed list is shared with the entire suite and tends to accumulate hundreds of test items, which both buries the assertion target and inflates the excludeNames URL). Boosts two synthetic names heavily so they lead the dropdown regardless of pollution in item_frequency, verifies "added" → "next fetch excludes it", and tears its own rows down via the admin purge RPC (direct table deletes are blocked by the deny-all RLS from migration 006). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
/**
|
|
* CI-series — Common items management (Fase 15).
|
|
*
|
|
* CI-01 Ana boosts "milk" → in /lists/<id> the dropdown shows it first
|
|
* even though seeded use_counts put other items higher.
|
|
* CI-02 Ana adds "pan" to the active list → the dropdown stops including
|
|
* it on the next keystroke.
|
|
* CI-03 Borja (member) opens /collective/manage → he sees the table but
|
|
* the action buttons are disabled.
|
|
* CI-04 Ana purges a row → the row disappears from the table and the
|
|
* dropdown stops showing it.
|
|
*
|
|
* Note: CI-02 lives in this file but exercises the /lists/[id] page, not
|
|
* /collective/manage — it pairs with the exclude-on-list wiring landing
|
|
* in the next commit (15.4). It is listed here because it is part of the
|
|
* Fase-15 contract.
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import { USERS } from '../fixtures/users.js';
|
|
import { loginAs } from '../fixtures/login.js';
|
|
|
|
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
|
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
|
const PREFIX = 'ci-e2e-';
|
|
|
|
/**
|
|
* Reset every item_frequency row this suite touched, and clear any boost
|
|
* we left on a seeded name. Runs via the page's `__sb` hook (service-role
|
|
* not exposed in the browser, so we go through Ana — who is admin in the
|
|
* seed collective — and the RPCs gate accordingly).
|
|
*/
|
|
async function resetCommonItems(page: import('@playwright/test').Page): Promise<void> {
|
|
await page.waitForFunction(
|
|
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
|
null,
|
|
{ timeout: 10_000 }
|
|
);
|
|
await page.evaluate(
|
|
async ({ collectiveId }) => {
|
|
const supabase = (
|
|
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
|
).__sb;
|
|
// Purge every name this suite is known to mutate. Direct table
|
|
// deletes are blocked by the deny-all RLS from migration 006;
|
|
// the admin RPC is the only delete path available to the browser.
|
|
for (const name of ['ci02alpha', 'ci02beta']) {
|
|
try {
|
|
await supabase.rpc('purge_item_frequency', {
|
|
p_collective_id: collectiveId,
|
|
p_name: name
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
// Reset weight on seeded names this suite mutates so a re-run starts
|
|
// from a known place.
|
|
for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) {
|
|
try {
|
|
await supabase.rpc('set_item_frequency_weight', {
|
|
p_collective_id: collectiveId,
|
|
p_name: name,
|
|
p_weight: 0
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
},
|
|
{ collectiveId: SEED_COLLECTIVE_ID }
|
|
);
|
|
}
|
|
|
|
test.describe('Common items management', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, USERS.ana);
|
|
await resetCommonItems(page);
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
try {
|
|
await resetCommonItems(page);
|
|
} catch {
|
|
/* don't mask original failure */
|
|
}
|
|
});
|
|
|
|
test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of higher-count items', async ({
|
|
page
|
|
}) => {
|
|
// Use a freshly-created list. The seed list is shared with the entire
|
|
// suite and tends to be polluted with hundreds of test items, which
|
|
// shifts the dropdown selection (other items end up with high
|
|
// use_counts from previous runs).
|
|
|
|
await page.goto('/collective/manage');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const yogurtRow = page.getByTestId('common-item-row-yogurt');
|
|
await expect(yogurtRow).toBeVisible({ timeout: 10_000 });
|
|
|
|
await yogurtRow.getByTestId('common-item-boost-yogurt').click();
|
|
await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 });
|
|
|
|
// Create an isolated empty list so the seed-pollution doesn't fill the
|
|
// dropdown with hundreds of other names.
|
|
const listId = await page.evaluate(async () => {
|
|
const supabase = (
|
|
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
|
).__sb;
|
|
const { data } = await supabase
|
|
.from('shopping_lists')
|
|
.insert({
|
|
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
name: `ci-01 list ${Date.now()}`,
|
|
created_by: '11111111-1111-1111-1111-111111111111'
|
|
})
|
|
.select('id')
|
|
.single();
|
|
return data?.id ?? '';
|
|
});
|
|
|
|
await page.goto(`/lists/${listId}`);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const strip = page.getByTestId('item-suggestions-strip');
|
|
await expect(strip).toBeVisible({ timeout: 10_000 });
|
|
// Yogurt now leads the dropdown thanks to its boost (weight=50).
|
|
const firstChip = strip.locator('button').first();
|
|
await expect(firstChip).toHaveText('yogurt');
|
|
|
|
// Cleanup
|
|
await page.evaluate(
|
|
async ({ id }) => {
|
|
const supabase = (
|
|
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
|
).__sb;
|
|
await supabase.from('shopping_lists').delete().eq('id', id);
|
|
},
|
|
{ id: listId }
|
|
);
|
|
});
|
|
|
|
test('CI-03: Borja sees the table read-only with disabled actions', async ({
|
|
browser
|
|
}) => {
|
|
// Seed the data as Ana first so there's something for Borja to see.
|
|
// (Default seed already has milk/bread/etc.)
|
|
const borjaCtx = await browser.newContext();
|
|
const borja = await borjaCtx.newPage();
|
|
try {
|
|
await loginAs(borja, USERS.borja);
|
|
await borja.goto('/collective/manage');
|
|
await borja.waitForLoadState('networkidle');
|
|
|
|
const milkRow = borja.getByTestId('common-item-row-milk');
|
|
await expect(milkRow).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Hide / Normal / Boost buttons must all be disabled for members.
|
|
const hideBtn = milkRow.getByTestId('common-item-hide-milk');
|
|
const normalBtn = milkRow.getByTestId('common-item-normal-milk');
|
|
const boostBtn = milkRow.getByTestId('common-item-boost-milk');
|
|
await expect(hideBtn).toBeDisabled();
|
|
await expect(normalBtn).toBeDisabled();
|
|
await expect(boostBtn).toBeDisabled();
|
|
|
|
// Purge button must be hidden or disabled for members.
|
|
const purgeBtn = milkRow.getByTestId('common-item-purge-milk');
|
|
await expect(purgeBtn).toBeDisabled();
|
|
|
|
// The "Add common item" button must not appear for members.
|
|
await expect(borja.getByTestId('common-item-add-button')).toHaveCount(0);
|
|
} finally {
|
|
await borjaCtx.close();
|
|
}
|
|
});
|
|
|
|
test('CI-02: items already in the list disappear from the suggestion strip', async ({
|
|
page
|
|
}) => {
|
|
// Create a fresh empty list (the seed "Weekly shop" is shared with the
|
|
// entire suite and tends to be polluted with hundreds of test items,
|
|
// which both buries the assertion target and inflates the excludeNames
|
|
// payload). Seed two item_frequency entries we control completely.
|
|
const listId = await page.evaluate(async () => {
|
|
const supabase = (
|
|
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
|
).__sb;
|
|
const { data } = await supabase
|
|
.from('shopping_lists')
|
|
.insert({
|
|
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
name: `ci-02 list ${Date.now()}`,
|
|
created_by: '11111111-1111-1111-1111-111111111111'
|
|
})
|
|
.select('id')
|
|
.single();
|
|
return data?.id ?? '';
|
|
});
|
|
expect(listId).toBeTruthy();
|
|
|
|
// Boost both names heavily so they lead the dropdown regardless of
|
|
// the pollution accumulated in item_frequency from other test runs.
|
|
await page.evaluate(
|
|
async ({ collective }) => {
|
|
const supabase = (
|
|
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
|
).__sb;
|
|
await supabase.rpc('set_item_frequency_weight', {
|
|
p_collective_id: collective,
|
|
p_name: 'ci02alpha',
|
|
p_weight: 100
|
|
});
|
|
await supabase.rpc('set_item_frequency_weight', {
|
|
p_collective_id: collective,
|
|
p_name: 'ci02beta',
|
|
p_weight: 100
|
|
});
|
|
},
|
|
{ collective: SEED_COLLECTIVE_ID }
|
|
);
|
|
|
|
await page.goto(`/lists/${listId}`);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const strip = page.getByTestId('item-suggestions-strip');
|
|
await expect(strip).toBeVisible({ timeout: 10_000 });
|
|
// Both seeded names appear in the dropdown (list is empty so nothing
|
|
// is excluded yet).
|
|
await expect(page.getByTestId('item-suggestion-ci02alpha')).toHaveCount(1);
|
|
await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1);
|
|
|
|
// Click the ci02alpha chip — the selectSuggestion handler fills the
|
|
// input and refocuses it; press Enter to submit.
|
|
await page.getByTestId('item-suggestion-ci02alpha').click();
|
|
const addForm = page.getByTestId('add-item-form');
|
|
// The first input inside add-item-form is the name field.
|
|
await addForm.locator('input').first().press('Enter');
|
|
|
|
// excludedItemNames recompute drops ci02alpha from the strip on the
|
|
// next debounced fetchSuggestions call.
|
|
await expect
|
|
.poll(async () => await page.getByTestId('item-suggestion-ci02alpha').count(), {
|
|
timeout: 10_000
|
|
})
|
|
.toBe(0);
|
|
// ci02beta is still in the list — sanity check the filter isn't over-eager.
|
|
await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1);
|
|
|
|
// Cleanup. Direct deletes from item_frequency are blocked by RLS — use
|
|
// the admin purge RPC.
|
|
await page.evaluate(
|
|
async ({ id, collective }) => {
|
|
const supabase = (
|
|
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
|
).__sb;
|
|
await supabase.from('shopping_items').delete().eq('list_id', id);
|
|
await supabase.from('shopping_lists').delete().eq('id', id);
|
|
for (const name of ['ci02alpha', 'ci02beta']) {
|
|
try {
|
|
await supabase.rpc('purge_item_frequency', {
|
|
p_collective_id: collective,
|
|
p_name: name
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
},
|
|
{ id: listId, collective: SEED_COLLECTIVE_ID }
|
|
);
|
|
});
|
|
|
|
test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({
|
|
page
|
|
}) => {
|
|
await page.goto('/collective/manage');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
const applesRow = page.getByTestId('common-item-row-apples');
|
|
await expect(applesRow).toBeVisible({ timeout: 10_000 });
|
|
|
|
await applesRow.getByTestId('common-item-purge-apples').click();
|
|
// Modal confirmation.
|
|
const confirmBtn = page.getByTestId('common-item-purge-confirm');
|
|
await expect(confirmBtn).toBeVisible({ timeout: 5_000 });
|
|
await confirmBtn.click();
|
|
|
|
// Row must vanish from the table.
|
|
await expect(applesRow).toHaveCount(0, { timeout: 5_000 });
|
|
|
|
// Re-seed apples via the existing trigger by NOT adding the item — just
|
|
// confirm the row is gone from the manage view. The dropdown behaviour
|
|
// is exercised indirectly by CI-01.
|
|
});
|
|
});
|