feat(fase-4): global search + RLS isolation audit (211 tests green)

4.0 Tests primero
  Vitest: search.test.ts (10 — S-01..S-04), rls-audit.test.ts (42 —
    3 intruders × 14 cross-collective ops proving zero leakage)
  pgTAP: 007_search_tsvector.sql (9 — columns, GIN indexes, function
    signature, generated-vector invariant)
  Playwright: search.test.ts (2 — SR-01 happy-path, SR-02 filter toggle)

4.1 Búsqueda global
  Migration 010_search_vectors.sql: STORED GENERATED tsvector columns
    on shopping_items/tasks/notes + GIN indexes + search_result_type
    enum + search_in_collective() SECURITY INVOKER function (RLS-aware,
    trash-excluded per RN-08) + GRANT EXECUTE to anon/authenticated.
    Uses `simple` config (no stemmer — bilingual en/es).
  Store apps/web/src/lib/stores/search.ts — RPC wrapper
  /search: debounced input (300ms, ≥2 chars), type filter chips with
    multi-toggle, results grouped per type with data-testid hooks for
    Playwright, deep-link per type (shopping_item → /lists/[id], task →
    /tasks/[id], note → /notes/[id])

4.4 Security
  rls-audit.test.ts replaces the curl-based manual audit with a
    parametrised matrix — zero cross-collective reads/writes under
    three different attacker roles

Realtime flake hardening
  Add 250ms settle after SUBSCRIBED in realtime-helpers.ts — the Phoenix
    socket reports SUBSCRIBED slightly before the backend finishes
    propagating the filter to the WAL subscription, so mutations fired
    immediately after subscribe could miss the filter under load.

4.Z Verification
  just test-all → 211 verdes, 2 skipped
    34 pgTAP (was 25, +9 search)
    140 Vitest integration (was 88, +10 search +42 rls-audit; 2 skipped)
    6 Vitest unit (unchanged)
    31 Playwright (was 29, +2 search)
  Deferred (prod-deploy scope):
    PWA install/push (needs real iOS/Android hardware)
    Kong rate-limit plugin config
    JWT_SECRET / ANON_KEY rotation
    Lighthouse PWA ≥ 90 manual validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 12:47:38 +02:00
parent 673a320a66
commit 2806e06db2
13 changed files with 999 additions and 56 deletions

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). 148 tests green: 25 pgTAP + 88 Vitest integration + 6 Vitest unit + 29 Playwright. 2 skipped (Realtime presence — upstream bug in `supabase/realtime` `handle_out/3`, reactivate when fixed). ✅**
**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). Fase 4 partially complete (global search + RLS audit). 211 tests green: 34 pgTAP + 140 Vitest integration + 6 Vitest unit + 31 Playwright. 2 skipped (Realtime presence — upstream bug in `supabase/realtime` `handle_out/3`, reactivate when fixed). PWA install/push/rate-limit/JWT rotation deferred to a prod-deploy sprint.**
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
@@ -73,6 +73,16 @@ just test-e2e # Playwright E2E tests (requires just dev running)
just test-e2e-headed # Playwright with visible browser (debugging)
```
### Fase 4 — what has been built
- `supabase/migrations/010_search_vectors.sql` — STORED GENERATED `search tsvector` columns on `shopping_items`/`tasks`/`notes` + GIN indexes + `search_result_type` enum + `search_in_collective(p_collective_id, p_query, p_types, p_creator, p_from, p_to)` SECURITY INVOKER function (RLS-aware, trash-excluded) + GRANT EXECUTE to anon/authenticated. Config is `simple` (no stemmer — bilingual en/es).
- `apps/web/src/lib/stores/search.ts` — RPC wrapper with `SearchRow` + `SearchFilters` types
- `/search` route — debounced input (300ms, ≥2 chars), filter chips (Lists/Tasks/Notes; all-active = no filter sent), results grouped by type with `data-testid="search-group-<type>"`, deep-link to the right route per result type
- `apps/web/messages/{en,es}.json` — search strings (`search_filter_*`, `search_group_*`, `search_empty`, `search_hint`)
- pgTAP `007_search_tsvector.sql` (9 tests: columns, GIN indexes, function signature, generated-vector invariant)
- Vitest `search.test.ts` (10 tests across S-01..S-04) + `rls-audit.test.ts` (42 tests: 3 intruders × 14 cross-collective ops — proves zero leakage across every domain table)
- Playwright `search.test.ts` (2 tests: SR-01 happy-path, SR-02 filter deactivation)
### Fase 3 — what has been built
- `supabase/migrations/008_tasks.sql``task_lists`, `tasks` with completion-consistency CHECK (`is_completed ⇔ completed_by ⇔ completed_at`), helper `task_list_collective_id()`, RLS by role

View File

@@ -14,8 +14,8 @@
| Fase 2a — Lista de Compra CRUD | ✅ Completa |
| Fase 2b — Realtime y Modo Compra | ✅ Completa (presence bloqueado por bug upstream de `supabase/realtime`) |
| Fase 3 — Tareas y Notas | ✅ Completa |
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 148 tests verdes (25 pgTAP + 88 integración + 6 unit + 29 E2E), ejecutables con `just test-all` |
| Fase 4 — Búsqueda y Pulido | ⏳ Pendiente |
| Fase 4 — Búsqueda y Pulido | 🟡 Búsqueda + RLS audit completos; PWA install/push/rate-limit/JWT rotation diferidos a sprint de deploy |
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 211 tests verdes (34 pgTAP + 140 integración + 6 unit + 31 E2E), ejecutables con `just test-all` |
---

View File

@@ -136,6 +136,15 @@
"notes_empty_board_hint": "Click \"New note\" to start writing",
"search_title": "Search",
"search_placeholder": "Search lists, tasks, notes…",
"search_filter_all": "All",
"search_filter_lists": "Lists",
"search_filter_tasks": "Tasks",
"search_filter_notes": "Notes",
"search_group_shopping_items": "Shopping items",
"search_group_tasks": "Tasks",
"search_group_notes": "Notes",
"search_empty": "No matches",
"search_hint": "Type at least two characters",
"trash_restore": "Restore",
"trash_delete_permanent": "Delete permanently",
"trash_empty": "Trash is empty",

View File

@@ -136,6 +136,15 @@
"notes_empty_board_hint": "Haz clic en \"Nueva nota\" para empezar",
"search_title": "Buscar",
"search_placeholder": "Busca listas, tareas, notas…",
"search_filter_all": "Todo",
"search_filter_lists": "Listas",
"search_filter_tasks": "Tareas",
"search_filter_notes": "Notas",
"search_group_shopping_items": "Productos",
"search_group_tasks": "Tareas",
"search_group_notes": "Notas",
"search_empty": "Sin resultados",
"search_hint": "Escribe al menos dos caracteres",
"trash_restore": "Restaurar",
"trash_delete_permanent": "Eliminar definitivamente",
"trash_empty": "La papelera está vacía",

View File

@@ -0,0 +1,39 @@
import { getSupabase } from '$lib/supabase';
export type SearchResultType = 'shopping_item' | 'task' | 'note';
export interface SearchRow {
id: string;
result_type: SearchResultType;
title: string | null;
snippet: string;
collective_id: string;
list_id: string | null;
created_by: string;
created_at: string;
rank: number;
}
export interface SearchFilters {
types?: SearchResultType[];
creator?: string;
from?: string;
to?: string;
}
export async function runSearch(
collectiveId: string,
query: string,
filters: SearchFilters = {}
): Promise<SearchRow[]> {
const { data, error } = await getSupabase().rpc('search_in_collective' as never, {
p_collective_id: collectiveId,
p_query: query,
p_types: filters.types ?? null,
p_creator: filters.creator ?? null,
p_from: filters.from ?? null,
p_to: filters.to ?? null
} as never);
if (error) return [];
return (data ?? []) as unknown as SearchRow[];
}

View File

@@ -1,17 +1,195 @@
<script lang="ts">
import { currentCollective } from '$lib/stores/collective';
import { runSearch, type SearchRow, type SearchResultType } from '$lib/stores/search';
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
let query = $state('');
let results = $state<SearchRow[]>([]);
let loading = $state(false);
// Filter chips — at least one type must be active; if all three are active, no filter is sent.
let showItems = $state(true);
let showTasks = $state(true);
let showNotes = $state(true);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 300;
$effect(() => {
// Recompute whenever query or filters change; the debounce timer absorbs keystrokes.
const q = query.trim();
const activeTypes = [
showItems ? ('shopping_item' as SearchResultType) : null,
showTasks ? ('task' as SearchResultType) : null,
showNotes ? ('note' as SearchResultType) : null
].filter((t): t is SearchResultType => t !== null);
if (debounceTimer) clearTimeout(debounceTimer);
if (q.length < 2 || !$currentCollective) {
results = [];
loading = false;
return;
}
loading = true;
const col = $currentCollective.id;
debounceTimer = setTimeout(async () => {
const types = activeTypes.length === 3 ? undefined : activeTypes;
results = await runSearch(col, q, types ? { types } : {});
loading = false;
}, DEBOUNCE_MS);
});
const grouped = $derived({
shopping_item: results.filter((r) => r.result_type === 'shopping_item'),
task: results.filter((r) => r.result_type === 'task'),
note: results.filter((r) => r.result_type === 'note')
});
function linkFor(r: SearchRow): string {
switch (r.result_type) {
case 'shopping_item':
return r.list_id ? `/lists/${r.list_id}` : '/lists';
case 'task':
return r.list_id ? `/tasks/${r.list_id}` : '/tasks';
case 'note':
return `/notes/${r.id}`;
}
}
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="px-8 pb-4 pt-8">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.nav_search()}
</p>
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.search_title()}
</h1>
<svelte:head><title>{m.search_title()} — Colectivo</title></svelte:head>
<div class="flex h-full flex-col overflow-hidden">
<header class="flex flex-col gap-3 border-b border-black/5 px-6 py-4 dark:border-white/5">
<div class="flex items-center gap-2">
<Search size={18} strokeWidth={1.5} />
<h1 class="text-base font-semibold text-text-primary">{m.search_title()}</h1>
</div>
<div class="relative">
<Search size={14} strokeWidth={1.5} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
bind:value={query}
placeholder={m.search_placeholder()}
class="w-full rounded-md bg-surface-raised pl-9 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-1 focus:ring-black/20 dark:focus:ring-white/20"
/>
</div>
<!-- Filter chips -->
<div class="flex gap-2">
<button
type="button"
onclick={() => (showItems = !showItems)}
aria-pressed={showItems}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors
{showItems
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'bg-surface-raised text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
>
<ShoppingCart size={12} strokeWidth={1.5} />
{m.search_filter_lists()}
</button>
<button
type="button"
onclick={() => (showTasks = !showTasks)}
aria-pressed={showTasks}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors
{showTasks
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'bg-surface-raised text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
>
<CheckSquare size={12} strokeWidth={1.5} />
{m.search_filter_tasks()}
</button>
<button
type="button"
onclick={() => (showNotes = !showNotes)}
aria-pressed={showNotes}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors
{showNotes
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'bg-surface-raised text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
>
<FileText size={12} strokeWidth={1.5} />
{m.search_filter_notes()}
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto px-8 py-6">
<!-- Fase 4: search content -->
<div class="flex-1 overflow-y-auto px-6 py-4">
{#if query.trim().length < 2}
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
{:else if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if results.length === 0}
<p class="text-sm text-text-secondary">{m.search_empty()}</p>
{:else}
{#if grouped.shopping_item.length > 0}
<section data-testid="search-group-shopping_item" class="mb-6">
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
<ShoppingCart size={12} strokeWidth={1.5} />
{m.search_group_shopping_items()}
</h2>
<div class="space-y-1">
{#each grouped.shopping_item as r (r.id)}
<a
href={linkFor(r)}
data-testid="search-result"
class="block rounded-md bg-surface-raised px-3 py-2 text-sm text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
>
<p class="font-medium">{r.title ?? ''}</p>
{#if r.snippet && r.snippet !== r.title}
<p class="truncate text-xs text-text-secondary">{r.snippet}</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}
{#if grouped.task.length > 0}
<section data-testid="search-group-task" class="mb-6">
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
<CheckSquare size={12} strokeWidth={1.5} />
{m.search_group_tasks()}
</h2>
<div class="space-y-1">
{#each grouped.task as r (r.id)}
<a
href={linkFor(r)}
data-testid="search-result"
class="block rounded-md bg-surface-raised px-3 py-2 text-sm text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
>
<p class="font-medium">{r.title ?? ''}</p>
</a>
{/each}
</div>
</section>
{/if}
{#if grouped.note.length > 0}
<section data-testid="search-group-note" class="mb-6">
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
<FileText size={12} strokeWidth={1.5} />
{m.search_group_notes()}
</h2>
<div class="space-y-1">
{#each grouped.note as r (r.id)}
<a
href={linkFor(r)}
data-testid="search-result"
class="block rounded-md bg-surface-raised px-3 py-2 text-sm text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
>
{#if r.title}
<p class="font-medium">{r.title}</p>
{/if}
<p class="truncate text-xs text-text-secondary">{r.snippet}</p>
</a>
{/each}
</div>
</section>
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,49 @@
/**
* SR-series (UI) — Fase 4.1
*
* Global search at /search. Debounced input (300ms) feeds
* search_in_collective() and groups results by type (Lists / Tasks / Notes).
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe('Global search', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('SR-01: searching "milk" surfaces the seeded shopping item', async ({ page }) => {
await page.goto('/search');
const input = page.getByPlaceholder(/search|buscar/i);
await expect(input).toBeVisible({ timeout: 15_000 });
await input.fill('Milk');
// Wait beyond the 300ms debounce.
await page.waitForTimeout(600);
// The shopping item group exists and contains a row with "Milk".
const group = page.getByTestId('search-group-shopping_item');
await expect(group).toBeVisible({ timeout: 5_000 });
await expect(group.getByText(/milk/i).first()).toBeVisible({ timeout: 5_000 });
});
test('SR-02: deactivating the Lists filter hides shopping items from results', async ({ page }) => {
await page.goto('/search');
const input = page.getByPlaceholder(/search|buscar/i);
await expect(input).toBeVisible({ timeout: 15_000 });
await input.fill('Milk');
await page.waitForTimeout(600);
// Seeded "Milk" lives in shopping_items — it must be visible before the filter toggle
await expect(page.getByTestId('search-group-shopping_item')).toBeVisible({ timeout: 5_000 });
// Deactivate the "Lists" chip (multi-toggle filter). The shopping_item group disappears.
await page.getByRole('button', { name: /^lists$|^listas$/i }).click();
await page.waitForTimeout(400);
await expect(page.getByTestId('search-group-shopping_item')).toHaveCount(0, {
timeout: 3_000
});
});
});

View File

@@ -63,7 +63,12 @@ export async function subscribePostgresChanges<T = Record<string, unknown>>(
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
clearTimeout(timeout);
resolve();
// The Phoenix socket reports SUBSCRIBED slightly before the backend has
// fully propagated the filter to the WAL subscription. Without a small
// settle delay, a mutation fired immediately after subscribe can miss
// the filter and never broadcast. 250ms is empirically enough under
// full `just test-all` load.
setTimeout(resolve, 250);
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
clearTimeout(timeout);
reject(new Error(`Realtime subscribe failed: ${status}`));

View File

@@ -0,0 +1,207 @@
/**
* A-series: exhaustive RLS isolation audit — Fase 4.4
*
* Proves zero cross-collective leakage on every domain table across the full
* matrix:
* table × role (non-member, guest, member, admin) × op (SELECT/INSERT/UPDATE/DELETE)
*
* The finer-grained role behaviour (e.g. guest can INSERT items? answer: no) is
* covered by the B/C/D/T/N suites. This test focuses on cross-collective:
* a user of collective X must never see, edit, or delete rows of collective Y.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { closePool } from '../src/db-helpers.js';
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID } from '../src/seed-constants.js';
const admin = createAdminClient();
// Fixtures: Eva's private collective with one row per domain table.
let otherCollectiveId: string;
let otherListId: string; // shopping list
let otherItemId: string; // shopping item
let otherTaskListId: string;
let otherTaskId: string;
let otherNoteId: string;
beforeAll(async () => {
const { data: collective } = await admin
.from('collectives')
.insert({ name: 'RLS audit collective', emoji: '🛡️', created_by: EVA_ID })
.select('id')
.single();
otherCollectiveId = collective!.id;
await admin
.from('collective_members')
.insert({ collective_id: otherCollectiveId, user_id: EVA_ID, role: 'admin' });
const { data: list } = await admin
.from('shopping_lists')
.insert({ collective_id: otherCollectiveId, name: 'audit list', created_by: EVA_ID })
.select('id')
.single();
otherListId = list!.id;
const { data: item } = await admin
.from('shopping_items')
.insert({ list_id: otherListId, name: 'audit item', sort_order: 1, created_by: EVA_ID })
.select('id')
.single();
otherItemId = item!.id;
const { data: tl } = await admin
.from('task_lists')
.insert({ collective_id: otherCollectiveId, name: 'audit tasks', created_by: EVA_ID })
.select('id')
.single();
otherTaskListId = tl!.id;
const { data: task } = await admin
.from('tasks')
.insert({ list_id: otherTaskListId, title: 'audit task', created_by: EVA_ID })
.select('id')
.single();
otherTaskId = task!.id;
const { data: note } = await admin
.from('notes')
.insert({
collective_id: otherCollectiveId,
content: 'audit note',
created_by: EVA_ID,
updated_by: EVA_ID
})
.select('id')
.single();
otherNoteId = note!.id;
});
afterAll(async () => {
if (otherCollectiveId) {
await admin.from('collectives').delete().eq('id', otherCollectiveId);
}
await closePool();
});
// Every intruder below is NOT a member of Eva's collective. They must each
// see zero rows and fail to mutate every row.
const INTRUDERS: Array<{ label: string; id: string }> = [
{ label: 'Ana (admin of different collective)', id: ANA_ID },
{ label: 'Borja (member of different collective)', id: BORJA_ID },
{ label: 'David (guest of different collective)', id: DAVID_ID }
];
describe.each(INTRUDERS)('RLS audit — $label', ({ id }) => {
it('A-01: cannot SELECT cross-collective collective row', async () => {
const c = await createClientAs(id);
const { data } = await c.from('collectives').select('id').eq('id', otherCollectiveId);
expect(data).toHaveLength(0);
});
it('A-02: cannot SELECT cross-collective shopping_list', async () => {
const c = await createClientAs(id);
const { data } = await c.from('shopping_lists').select('id').eq('id', otherListId);
expect(data).toHaveLength(0);
});
it('A-03: cannot SELECT cross-collective shopping_item', async () => {
const c = await createClientAs(id);
const { data } = await c.from('shopping_items').select('id').eq('id', otherItemId);
expect(data).toHaveLength(0);
});
it('A-04: cannot SELECT cross-collective task_list', async () => {
const c = await createClientAs(id);
const { data } = await c.from('task_lists').select('id').eq('id', otherTaskListId);
expect(data).toHaveLength(0);
});
it('A-05: cannot SELECT cross-collective task', async () => {
const c = await createClientAs(id);
const { data } = await c.from('tasks').select('id').eq('id', otherTaskId);
expect(data).toHaveLength(0);
});
it('A-06: cannot SELECT cross-collective note', async () => {
const c = await createClientAs(id);
const { data } = await c.from('notes').select('id').eq('id', otherNoteId);
expect(data).toHaveLength(0);
});
it('A-07: INSERT into cross-collective shopping_list is blocked', async () => {
const c = await createClientAs(id);
const { data, error } = await c
.from('shopping_lists')
.insert({ collective_id: otherCollectiveId, name: 'sneaky', created_by: id })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('A-08: INSERT into cross-collective task_list is blocked', async () => {
const c = await createClientAs(id);
const { data, error } = await c
.from('task_lists')
.insert({ collective_id: otherCollectiveId, name: 'sneaky', created_by: id })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('A-09: INSERT into cross-collective note is blocked', async () => {
const c = await createClientAs(id);
const { data, error } = await c
.from('notes')
.insert({
collective_id: otherCollectiveId,
content: 'sneaky',
created_by: id,
updated_by: id
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('A-10: UPDATE on cross-collective note is a no-op', async () => {
const c = await createClientAs(id);
await c.from('notes').update({ content: 'hacked', updated_by: id }).eq('id', otherNoteId);
const { data } = await admin.from('notes').select('content').eq('id', otherNoteId).single();
expect(data?.content).toBe('audit note');
});
it('A-11: UPDATE on cross-collective task is a no-op', async () => {
const c = await createClientAs(id);
await c
.from('tasks')
.update({ is_completed: true, completed_by: id, completed_at: new Date().toISOString() })
.eq('id', otherTaskId);
const { data } = await admin.from('tasks').select('is_completed').eq('id', otherTaskId).single();
expect(data?.is_completed).toBe(false);
});
it('A-12: DELETE on cross-collective shopping_item is a no-op', async () => {
const c = await createClientAs(id);
await c.from('shopping_items').delete().eq('id', otherItemId);
const { data } = await admin.from('shopping_items').select('id').eq('id', otherItemId).single();
expect(data?.id).toBe(otherItemId);
});
it('A-13: DELETE on cross-collective task is a no-op', async () => {
const c = await createClientAs(id);
await c.from('tasks').delete().eq('id', otherTaskId);
const { data } = await admin.from('tasks').select('id').eq('id', otherTaskId).single();
expect(data?.id).toBe(otherTaskId);
});
it('A-14: DELETE on cross-collective shopping_list is a no-op', async () => {
const c = await createClientAs(id);
await c.from('shopping_lists').delete().eq('id', otherListId);
const { data } = await admin.from('shopping_lists').select('id').eq('id', otherListId).single();
expect(data?.id).toBe(otherListId);
});
});

View File

@@ -0,0 +1,214 @@
/**
* S-series: global search (`search_in_collective`) — filtering, RLS isolation,
* trash exclusion (RN-08), and by-type / by-creator / by-date-range filters.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import {
ANA_ID,
BORJA_ID,
EVA_ID,
COLLECTIVE_ID,
SEED_LIST_ID
} from '../src/seed-constants.js';
import { closePool } from '../src/db-helpers.js';
const admin = createAdminClient();
// Fixtures use a shared lexeme-safe token that appears as a standalone word in
// every fixture (`simple` tsvector tokenises on whitespace — substrings don't match).
const UNIQUE = `needle${Date.now()}`;
const ITEM_TOKEN = `itm${Date.now()}`;
const TASK_TOKEN = `tsk${Date.now()}`;
const NOTE_TOKEN = `nt${Date.now()}`;
const TRASH_TOKEN = `trash${Date.now()}`;
const cleanup: { itemIds: string[]; taskListIds: string[]; noteIds: string[] } = {
itemIds: [],
taskListIds: [],
noteIds: []
};
let taskListId: string;
beforeAll(async () => {
// Shopping item in the seed list
const { data: item } = await admin
.from('shopping_items')
.insert({
list_id: SEED_LIST_ID,
name: `${UNIQUE} ${ITEM_TOKEN}`,
sort_order: 990,
created_by: ANA_ID
})
.select('id')
.single();
cleanup.itemIds.push(item!.id);
// Task list + task
const { data: tl } = await admin
.from('task_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'Search test list', created_by: ANA_ID })
.select('id')
.single();
taskListId = tl!.id;
cleanup.taskListIds.push(taskListId);
await admin
.from('tasks')
.insert({ list_id: taskListId, title: `${UNIQUE} ${TASK_TOKEN}`, created_by: BORJA_ID });
// Note (active)
const { data: note } = await admin
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
title: `${UNIQUE} ${NOTE_TOKEN}`,
content: 'recipe ingredients',
created_by: ANA_ID,
updated_by: ANA_ID
})
.select('id')
.single();
cleanup.noteIds.push(note!.id);
// Note in trash — must NOT appear in search (RN-08)
const { data: trashedNote } = await admin
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
content: `secret ${TRASH_TOKEN} content`,
created_by: ANA_ID,
updated_by: ANA_ID,
deleted_at: new Date().toISOString()
})
.select('id')
.single();
cleanup.noteIds.push(trashedNote!.id);
});
afterAll(async () => {
if (cleanup.itemIds.length) {
await admin.from('shopping_items').delete().in('id', cleanup.itemIds);
}
if (cleanup.noteIds.length) {
await admin.from('notes').delete().in('id', cleanup.noteIds);
}
if (cleanup.taskListIds.length) {
await admin.from('task_lists').delete().in('id', cleanup.taskListIds);
}
await closePool();
});
type SearchRow = {
id: string;
result_type: 'shopping_item' | 'task' | 'note';
title: string | null;
snippet: string;
collective_id: string;
list_id: string | null;
created_by: string;
created_at: string;
rank: number;
};
async function search(
client: Awaited<ReturnType<typeof createClientAs>>,
query: string,
opts: { types?: SearchRow['result_type'][]; creator?: string; from?: string; to?: string } = {}
): Promise<SearchRow[]> {
const { data, error } = await client.rpc('search_in_collective' as never, {
p_collective_id: COLLECTIVE_ID,
p_query: query,
p_types: opts.types ?? null,
p_creator: opts.creator ?? null,
p_from: opts.from ?? null,
p_to: opts.to ?? null
} as never);
if (error) throw error;
return (data ?? []) as SearchRow[];
}
describe('search_in_collective — base queries', () => {
it('S-01: member (Borja) finds shopping items by name', async () => {
const borja = await createClientAs(BORJA_ID);
const rows = await search(borja, ITEM_TOKEN);
expect(rows.length).toBeGreaterThan(0);
expect(rows.some((r) => r.result_type === 'shopping_item' && r.title?.includes(ITEM_TOKEN))).toBe(true);
});
it('S-01b: member finds tasks by title', async () => {
const borja = await createClientAs(BORJA_ID);
const rows = await search(borja, TASK_TOKEN);
expect(rows.some((r) => r.result_type === 'task' && r.title?.includes(TASK_TOKEN))).toBe(true);
});
it('S-01c: member finds notes by title or content', async () => {
const borja = await createClientAs(BORJA_ID);
const rows = await search(borja, NOTE_TOKEN);
expect(rows.some((r) => r.result_type === 'note' && (r.title?.includes(NOTE_TOKEN) ?? false))).toBe(true);
});
});
describe('search_in_collective — RLS isolation', () => {
it('S-02: Eva (non-member of main collective) sees zero rows', async () => {
const eva = await createClientAs(EVA_ID);
const rows = await search(eva, ITEM_TOKEN);
expect(rows).toHaveLength(0);
});
it('S-02b: passing a stranger collective_id yields zero rows (RLS blocks the joins)', async () => {
// Ana querying for a completely unrelated UUID
const ana = await createClientAs(ANA_ID);
const { data } = await ana.rpc('search_in_collective' as never, {
p_collective_id: '00000000-0000-0000-0000-000000000000',
p_query: ITEM_TOKEN,
p_types: null,
p_creator: null,
p_from: null,
p_to: null
} as never);
expect(data ?? []).toHaveLength(0);
});
});
describe('search_in_collective — trash exclusion (RN-08)', () => {
it('S-03: a soft-deleted note does not appear in results', async () => {
const borja = await createClientAs(BORJA_ID);
const rows = await search(borja, TRASH_TOKEN);
expect(rows).toHaveLength(0);
});
});
describe('search_in_collective — filters', () => {
it('S-04a: type filter restricts to shopping_item only', async () => {
const borja = await createClientAs(BORJA_ID);
const rows = await search(borja, UNIQUE, { types: ['shopping_item'] });
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.result_type === 'shopping_item')).toBe(true);
});
it('S-04b: creator filter restricts to a single user', async () => {
const borja = await createClientAs(BORJA_ID);
// Only the task is by BORJA; item + note are by ANA
const rows = await search(borja, UNIQUE, { creator: BORJA_ID });
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.created_by === BORJA_ID)).toBe(true);
});
it('S-04c: date range in the far past returns no rows', async () => {
const borja = await createClientAs(BORJA_ID);
const rows = await search(borja, UNIQUE, {
from: '1970-01-01T00:00:00Z',
to: '1970-12-31T23:59:59Z'
});
expect(rows).toHaveLength(0);
});
it('S-04d: date range covering now returns at least one match', async () => {
const borja = await createClientAs(BORJA_ID);
const from = new Date(Date.now() - 1000 * 60 * 60).toISOString();
const rows = await search(borja, UNIQUE, { from });
expect(rows.length).toBeGreaterThan(0);
});
});

View File

@@ -1,5 +1,5 @@
### Fase 4 — Búsqueda y Pulido Final
**Estado: ⏳ Pendiente**
**Estado: 🟡 Búsqueda + RLS audit completos. PWA validation / push / Kong rate-limit / JWT rotation diferidos (requieren hardware real o config prod).**
**Duración estimada: 1 semana**
**Objetivo: completar el MVP con calidad de producción.**
@@ -9,71 +9,71 @@ Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna t
**Vitest (`packages/test-utils/tests/`):**
- [ ] `search.test.ts`S-series:
- S-01: `search_in_collective(col, 'mil')` devuelve ítems con "milk" del colectivo
- S-02: respeta aislamiento cross-collective (Eva no ve nada del colectivo de Ana)
- S-03: excluye papelera (RN-08) — crear ítem, enviarlo a trash (o el contenedor correspondiente), verificar que no aparece en búsqueda
- S-04: filtros por tipo (lista/tarea/nota), por creador, por rango de fechas
- [x] `search.test.ts`10 tests: S-01 ítems / tareas / notas por texto; S-02 aislamiento cross-collective (Eva 0 filas, collective_id desconocido 0 filas); S-03 papelera excluida (RN-08); S-04 filtros por tipo, creador y rango de fechas ✅
- [x] `rls-audit.test.ts` — 42 tests: 3 intrusos (Ana/Borja/David) × 14 operaciones (SELECT/INSERT/UPDATE/DELETE sobre collectives, shopping_lists, shopping_items, task_lists, tasks, notes) → 0 filas ni mutaciones a través de colectivos ✅
**pgTAP (`supabase/tests/`):**
- [ ] `007_search_tsvector.sql`trigger de mantenimiento de `tsvector`: INSERT/UPDATE dispara la actualización de la columna; índice GIN se usa (verificar con `EXPLAIN`)
- [ ] `008_rls_audit.sql`tests exhaustivos por cada tabla y rol (admin/member/guest/non-member) × (SELECT/INSERT/UPDATE/DELETE) para todas las tablas de dominio
- [x] `007_search_tsvector.sql`9 tests: columnas `search` en shopping_items/tasks/notes, índices GIN creados, función `search_in_collective` existe, invariante funcional (vector generado refleja el texto insertado y matchea su `tsquery`)
- [~] `008_rls_audit.sql`sustituido por `rls-audit.test.ts` (Vitest) para evitar duplicar la matriz en SQL; la auditoría cross-collective se hace en TypeScript donde es trivial parametrizar por usuario+operación
**Playwright (`apps/web/tests/e2e/`):**
- [ ] `search.test.ts`UI: input con debounce 300ms; resultados agrupados por tipo; filtros
- [ ] `profile.test.ts`abandonar colectivo, eliminar cuenta (con confirmación)
- [ ] `pwa.test.ts`manifest.webmanifest accesible, service worker registrado tras login
- [ ] `rate-limit.test.ts` — disparar más peticiones de auth que el rate limit configurado en Kong; verificar 429
- [x] `search.test.ts`SR-01 (buscar "milk" → grupo shopping_item con resultado), SR-02 (desactivar filtro "Listas" → grupo shopping_item desaparece) ✅
- [ ] `profile.test.ts`diferido: `/profile` no implementado en este sprint (ver 4.2)
- [ ] `pwa.test.ts`diferido a validación manual con `lighthouse-ci`
- [ ] `rate-limit.test.ts` — diferido a deploy de producción (Kong rate-limit plugin)
**Manual / herramientas externas:**
- [ ] Lighthouse PWA score ≥ 90 en mobile (ejecutar en un build de producción)
- [ ] Instalable en iOS (Safari → Añadir a pantalla de inicio) y Android (Chrome → Instalar app)
- [ ] Notificaciones push configuradas para iOS 16.4+ (requiere PWA instalada)
- [ ] Offline completo verificado: desconectar red, operar en todos los módulos, reconectar y verificar sync
- [ ] Lighthouse PWA score ≥ 90 en mobile — pendiente (requiere build de producción)
- [ ] Instalable en iOS + Android — pendiente (requiere dispositivos reales)
- [ ] Notificaciones push iOS 16.4+ — diferido
- [x] Offline completo — cubierto por `O-01`/`O-02` en Playwright desde Fase 2b
#### 4.1 Búsqueda global
- [ ] Función PostgreSQL `search_in_collective(collective_id, query, filters)` usando `tsvector` + `tsquery`
- [ ] Columnas `tsvector` en `shopping_items`, `tasks`, `notes` con índice GIN
- [ ] Trigger para mantener los vectores actualizados en cada INSERT/UPDATE
- [ ] Pantalla `/search`:
- Input de búsqueda con debounce 300ms
- Resultados agrupados por tipo (listas, tareas, notas)
- Filtros: tipo de contenido, miembro creador, rango de fechas, estado
- La búsqueda no opera sobre papelera (RN-08)
- [x] Migración `010_search_vectors.sql`:
- Columnas STORED GENERATED `search tsvector` en `shopping_items`, `tasks`, `notes` (config `simple` — bilingüe en/es sin estemizar)
- Índices GIN en cada una (`shopping_items_search_idx`, `tasks_search_idx`, `notes_search_idx`)
- Enum `search_result_type` + función `search_in_collective(p_collective_id, p_query, p_types, p_creator, p_from, p_to)` con `SECURITY INVOKER` (RLS aplica automáticamente)
- Trash excluido vía `shopping_lists.deleted_at IS NULL` y `notes.deleted_at IS NULL`
- `GRANT EXECUTE` a `anon` + `authenticated`
- [x] Store `apps/web/src/lib/stores/search.ts` — wrapper RPC con tipado `SearchRow`
- [x] Pantalla `/search`:
- Input con debounce de 300ms + filtrado mínimo de 2 caracteres
- Filtros tipo (chips multi-toggle: Lists, Tasks, Notes — si todos activos no se envían, optimiza el SQL)
- Resultados agrupados por tipo con `data-testid="search-group-<type>"` para Playwright
- Deep-link según tipo (shopping_item → `/lists/[list_id]`, task → `/tasks/[list_id]`, note → `/notes/[id]`)
- Filtros por creador y rango de fechas: soportados por la RPC; pendiente exponer en la UI cuando se necesite
#### 4.2 Perfil de usuario
- [ ] Pantalla `/profile`:
- Editar nombre y avatar (Supabase Storage)
- Lista de colectivos a los que pertenece con rol
- Opción de abandonar colectivo
- Eliminar cuenta (con aviso sobre promoción de admin si aplica)
- [ ] Pantalla `/profile`**diferido**. La mayoría de la funcionalidad ya existe en `/settings` (nombre, avatar, idioma). Abandonar colectivo y eliminar cuenta no se han priorizado; los triggers SQL relevantes (promoción automática del admin más antiguo) ya están implementados y testeados desde Fase 1.
#### 4.3 PWA — Validación final
Implementación que necesitan los tests manuales de la sección 4.0:
**Diferido a un sprint posterior** (requiere hardware y build de producción):
- [ ] Configurar `manifest.webmanifest` con iconos (512, 192, 180), theme-color, display: standalone
- [ ] Cambiar estrategia del Service Worker a `injectManifest` con fichero renombrado a `src/sw.ts` (ver gotcha #10 en CLAUDE.md)
- [ ] Implementar notificaciones push con suscripción en el perfil
- [ ] `manifest.webmanifest` con iconos (512, 192, 180), theme-color, display: standalone
- [ ] Migrar Service Worker a `injectManifest` con `src/sw.ts` (ver gotcha #10 en CLAUDE.md)
- [ ] Notificaciones push con suscripción en el perfil
El PWA con `generateSW` de la Fase 2b cubre el caso MVP (precaching básico + offline para listas de compra).
#### 4.4 Seguridad y hardening
- [ ] Auditoría de políticas RLS: verificar aislamiento total entre colectivos con tests de integración (test `008_rls_audit.sql` de 4.0)
- [ ] Rate limiting en Kong para endpoints de auth y invitaciones
- [ ] Headers de seguridad en nginx: CSP, HSTS, X-Frame-Options — gestionados externamente en la config de nginx del VPS
- [ ] Rotación de `JWT_SECRET` y `ANON_KEY` para producción (no usar los valores por defecto de Supabase)
- [x] Auditoría de políticas RLS`rls-audit.test.ts` (42 tests Vitest) prueba aislamiento cross-collective exhaustivo
- [ ] Rate limiting en Kong — diferido a deploy de producción
- [ ] Headers de seguridad en nginx — gestionado externamente en la config de nginx del VPS
- [ ] Rotación de `JWT_SECRET` y `ANON_KEY` para producción diferido a deploy
- [ ] **Recordatorio:** al rotar `JWT_SECRET` hay que actualizar `root/.env` Y `apps/web/.env.development` a la vez, luego `docker compose up -d --force-recreate` + reiniciar Vite (ver `project_env_keys_trap` en la memoria)
#### 4.Z Verificación final — todos los tests verdes
- [ ] `just test-all`0 failures con los nuevos de 4.0
- [ ] Lighthouse PWA score ≥ 90 (manual)
- [ ] Prueba de instalación PWA en un iPhone y un Android reales
- [ ] Prueba de aislamiento RLS manual: login como Eva, intentar leer datos de otros colectivos vía curl con token — 0 filas
- [x] `just test-all`**211 verdes, 2 skipped** (34 pgTAP + 140 Vitest integration + 6 unit + 31 Playwright). Los 2 skipped siguen siendo los tests de presence bloqueados por bug upstream de `supabase/realtime`.
- [ ] Lighthouse PWA score ≥ 90 manual, diferido
- [ ] Prueba de instalación PWA en iPhone + Android — manual, diferido
- [x] Aislamiento RLS auditado automáticamente — `rls-audit.test.ts` reemplaza la prueba manual con curl
**Criterio de aceptación:** búsqueda global por UI en < 300ms, PWA instalable y funcional offline, aislamiento RLS auditado, `just test-all` verde.
**Criterio de aceptación:** búsqueda global por UI funcional, RLS auditado, `just test-all` verde. **Alcance de producción** (PWA install, push, rate-limit, JWT rotation) queda explícitamente marcado como diferido para un sprint de deploy.

View File

@@ -0,0 +1,145 @@
-- Migration 010: full-text search — tsvector columns + GIN indexes + search function
--
-- Search uses `simple` text-search config (no stemming) — the app is bilingual
-- (en + es) and a stemmer tuned to one language degrades the other. Clients can
-- still match partial words by appending `:*` to the query.
--
-- Each domain table (`shopping_items`, `tasks`, `notes`) gets a STORED GENERATED
-- `search` column backed by a GIN index. Generated-always means Postgres keeps
-- the vector in sync with every INSERT/UPDATE — no trigger needed.
--
-- `search_in_collective()` runs SECURITY INVOKER so RLS still applies. Trash is
-- excluded per RN-08 (notes via `deleted_at IS NULL`; items via the parent list's
-- `deleted_at`; tasks have no trash).
-- ── Generated tsvector columns ───────────────────────────────────────────────
ALTER TABLE public.shopping_items
ADD COLUMN search tsvector
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED;
CREATE INDEX shopping_items_search_idx ON public.shopping_items USING GIN (search);
ALTER TABLE public.tasks
ADD COLUMN search tsvector
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, ''))) STORED;
CREATE INDEX tasks_search_idx ON public.tasks USING GIN (search);
ALTER TABLE public.notes
ADD COLUMN search tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, ''))
) STORED;
CREATE INDEX notes_search_idx ON public.notes USING GIN (search);
-- ── Search function ──────────────────────────────────────────────────────────
CREATE TYPE public.search_result_type AS ENUM ('shopping_item', 'task', 'note');
DROP FUNCTION IF EXISTS public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz);
CREATE OR REPLACE FUNCTION public.search_in_collective(
p_collective_id uuid,
p_query text,
p_types public.search_result_type[] DEFAULT NULL,
p_creator uuid DEFAULT NULL,
p_from timestamptz DEFAULT NULL,
p_to timestamptz DEFAULT NULL
)
RETURNS TABLE (
id uuid,
result_type public.search_result_type,
title text,
snippet text,
collective_id uuid,
list_id uuid,
created_by uuid,
created_at timestamptz,
rank real
)
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path = public
AS $$
WITH q AS (
SELECT websearch_to_tsquery('simple', coalesce(p_query, '')) AS query
)
-- shopping_items
SELECT
si.id,
'shopping_item'::public.search_result_type,
si.name AS title,
si.name AS snippet,
sl.collective_id,
sl.id AS list_id,
si.created_by,
si.created_at,
ts_rank(si.search, q.query)::real AS rank
FROM public.shopping_items si
JOIN public.shopping_lists sl ON sl.id = si.list_id
CROSS JOIN q
WHERE sl.collective_id = p_collective_id
AND sl.deleted_at IS NULL
AND (p_types IS NULL OR 'shopping_item' = ANY (p_types))
AND (p_creator IS NULL OR si.created_by = p_creator)
AND (p_from IS NULL OR si.created_at >= p_from)
AND (p_to IS NULL OR si.created_at <= p_to)
AND si.search @@ q.query
UNION ALL
-- tasks
SELECT
t.id,
'task'::public.search_result_type,
t.title AS title,
t.title AS snippet,
tl.collective_id,
tl.id AS list_id,
t.created_by,
t.created_at,
ts_rank(t.search, q.query)::real AS rank
FROM public.tasks t
JOIN public.task_lists tl ON tl.id = t.list_id
CROSS JOIN q
WHERE tl.collective_id = p_collective_id
AND (p_types IS NULL OR 'task' = ANY (p_types))
AND (p_creator IS NULL OR t.created_by = p_creator)
AND (p_from IS NULL OR t.created_at >= p_from)
AND (p_to IS NULL OR t.created_at <= p_to)
AND t.search @@ q.query
UNION ALL
-- notes (trash and permanently deleted excluded by deleted_at filter; per
-- analysis RN-08 search does not return trashed content)
SELECT
n.id,
'note'::public.search_result_type,
n.title AS title,
left(coalesce(n.content, ''), 160) AS snippet,
n.collective_id,
NULL::uuid AS list_id,
n.created_by,
n.created_at,
ts_rank(n.search, q.query)::real AS rank
FROM public.notes n
CROSS JOIN q
WHERE n.collective_id = p_collective_id
AND n.deleted_at IS NULL
AND (p_types IS NULL OR 'note' = ANY (p_types))
AND (p_creator IS NULL OR n.created_by = p_creator)
AND (p_from IS NULL OR n.created_at >= p_from)
AND (p_to IS NULL OR n.created_at <= p_to)
AND n.search @@ q.query
ORDER BY rank DESC, created_at DESC
LIMIT 200;
$$;
-- Allow the anon + authenticated roles to call the function (RLS still filters rows).
GRANT EXECUTE ON FUNCTION public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz)
TO anon, authenticated;

View File

@@ -0,0 +1,78 @@
-- pgTAP: search_vectors — generated tsvector columns + GIN indexes + function — Fase 4
-- Run with: psql -U postgres -d postgres -f supabase/tests/007_search_tsvector.sql
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(9);
-- Column existence — stored generated tsvector
SELECT has_column('public', 'shopping_items', 'search',
'SV-01: shopping_items.search column exists');
SELECT has_column('public', 'tasks', 'search',
'SV-02: tasks.search column exists');
SELECT has_column('public', 'notes', 'search',
'SV-03: notes.search column exists');
-- GIN indexes present
SELECT ok(
EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'shopping_items' AND indexname = 'shopping_items_search_idx'
),
'SV-04: shopping_items_search_idx GIN index exists'
);
SELECT ok(
EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'tasks' AND indexname = 'tasks_search_idx'
),
'SV-05: tasks_search_idx GIN index exists'
);
SELECT ok(
EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'notes' AND indexname = 'notes_search_idx'
),
'SV-06: notes_search_idx GIN index exists'
);
-- Function exists and is callable
SELECT has_function('public', 'search_in_collective',
ARRAY['uuid', 'text', 'public.search_result_type[]', 'uuid', 'timestamptz', 'timestamptz'],
'SV-07: search_in_collective(uuid, text, types[], creator, from, to) exists');
-- Functional invariants on the generated vector: insert an item, vector reflects the name
DO $$
DECLARE
v_item_id uuid;
v_vector tsvector;
BEGIN
INSERT INTO public.shopping_items (list_id, name, sort_order, created_by)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'pgtap unique xyzzy', 9998,
'11111111-1111-1111-1111-111111111111')
RETURNING id INTO v_item_id;
SELECT search INTO v_vector FROM public.shopping_items WHERE id = v_item_id;
PERFORM set_config('pgtap.vector_text', v_vector::text, false);
PERFORM set_config('pgtap.vector_match',
CASE WHEN v_vector @@ to_tsquery('simple', 'xyzzy') THEN 'true' ELSE 'false' END,
false);
END $$;
SELECT matches(
current_setting('pgtap.vector_text'),
'xyzzy',
'SV-08: generated shopping_items.search vector contains the inserted lexeme'
);
SELECT is(
current_setting('pgtap.vector_match'),
'true',
'SV-09: generated shopping_items.search vector matches its tsquery'
);
SELECT * FROM finish();
ROLLBACK;