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:
12
CLAUDE.md
12
CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## 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
|
- `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)
|
- `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)
|
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
|
### 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
|
- `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
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
| Fase 2a — Lista de Compra CRUD | ✅ Completa |
|
| Fase 2a — Lista de Compra CRUD | ✅ Completa |
|
||||||
| Fase 2b — Realtime y Modo Compra | ✅ Completa (presence bloqueado por bug upstream de `supabase/realtime`) |
|
| Fase 2b — Realtime y Modo Compra | ✅ Completa (presence bloqueado por bug upstream de `supabase/realtime`) |
|
||||||
| Fase 3 — Tareas y Notas | ✅ Completa |
|
| 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 | 🟡 Búsqueda + RLS audit completos; PWA install/push/rate-limit/JWT rotation diferidos a sprint de deploy |
|
||||||
| Fase 4 — Búsqueda y Pulido | ⏳ Pendiente |
|
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 211 tests verdes (34 pgTAP + 140 integración + 6 unit + 31 E2E), ejecutables con `just test-all` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,15 @@
|
|||||||
"notes_empty_board_hint": "Click \"New note\" to start writing",
|
"notes_empty_board_hint": "Click \"New note\" to start writing",
|
||||||
"search_title": "Search",
|
"search_title": "Search",
|
||||||
"search_placeholder": "Search lists, tasks, notes…",
|
"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_restore": "Restore",
|
||||||
"trash_delete_permanent": "Delete permanently",
|
"trash_delete_permanent": "Delete permanently",
|
||||||
"trash_empty": "Trash is empty",
|
"trash_empty": "Trash is empty",
|
||||||
|
|||||||
@@ -136,6 +136,15 @@
|
|||||||
"notes_empty_board_hint": "Haz clic en \"Nueva nota\" para empezar",
|
"notes_empty_board_hint": "Haz clic en \"Nueva nota\" para empezar",
|
||||||
"search_title": "Buscar",
|
"search_title": "Buscar",
|
||||||
"search_placeholder": "Busca listas, tareas, notas…",
|
"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_restore": "Restaurar",
|
||||||
"trash_delete_permanent": "Eliminar definitivamente",
|
"trash_delete_permanent": "Eliminar definitivamente",
|
||||||
"trash_empty": "La papelera está vacía",
|
"trash_empty": "La papelera está vacía",
|
||||||
|
|||||||
39
apps/web/src/lib/stores/search.ts
Normal file
39
apps/web/src/lib/stores/search.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -1,17 +1,195 @@
|
|||||||
<script lang="ts">
|
<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';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<svelte:head><title>{m.search_title()} — Colectivo</title></svelte:head>
|
||||||
<header class="px-8 pb-4 pt-8">
|
|
||||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
{m.nav_search()}
|
<header class="flex flex-col gap-3 border-b border-black/5 px-6 py-4 dark:border-white/5">
|
||||||
</p>
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
<Search size={18} strokeWidth={1.5} />
|
||||||
{m.search_title()}
|
<h1 class="text-base font-semibold text-text-primary">{m.search_title()}</h1>
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
49
apps/web/tests/e2e/search.test.ts
Normal file
49
apps/web/tests/e2e/search.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,12 @@ export async function subscribePostgresChanges<T = Record<string, unknown>>(
|
|||||||
channel.subscribe((status) => {
|
channel.subscribe((status) => {
|
||||||
if (status === 'SUBSCRIBED') {
|
if (status === 'SUBSCRIBED') {
|
||||||
clearTimeout(timeout);
|
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') {
|
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
reject(new Error(`Realtime subscribe failed: ${status}`));
|
reject(new Error(`Realtime subscribe failed: ${status}`));
|
||||||
|
|||||||
207
packages/test-utils/tests/rls-audit.test.ts
Normal file
207
packages/test-utils/tests/rls-audit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
214
packages/test-utils/tests/search.test.ts
Normal file
214
packages/test-utils/tests/search.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
### Fase 4 — Búsqueda y Pulido Final
|
### 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**
|
**Duración estimada: 1 semana**
|
||||||
**Objetivo: completar el MVP con calidad de producción.**
|
**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/`):**
|
**Vitest (`packages/test-utils/tests/`):**
|
||||||
|
|
||||||
- [ ] `search.test.ts` — S-series:
|
- [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 ✅
|
||||||
- S-01: `search_in_collective(col, 'mil')` devuelve ítems con "milk" del colectivo
|
- [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 ✅
|
||||||
- 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
|
|
||||||
|
|
||||||
**pgTAP (`supabase/tests/`):**
|
**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`)
|
- [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` — tests exhaustivos por cada tabla y rol (admin/member/guest/non-member) × (SELECT/INSERT/UPDATE/DELETE) para todas las tablas de dominio
|
- [~] `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/`):**
|
**Playwright (`apps/web/tests/e2e/`):**
|
||||||
|
|
||||||
- [ ] `search.test.ts` — UI: input con debounce 300ms; resultados agrupados por tipo; filtros
|
- [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` — abandonar colectivo, eliminar cuenta (con confirmación)
|
- [ ] `profile.test.ts` — diferido: `/profile` no implementado en este sprint (ver 4.2)
|
||||||
- [ ] `pwa.test.ts` — manifest.webmanifest accesible, service worker registrado tras login
|
- [ ] `pwa.test.ts` — diferido a validación manual con `lighthouse-ci`
|
||||||
- [ ] `rate-limit.test.ts` — disparar más peticiones de auth que el rate limit configurado en Kong; verificar 429
|
- [ ] `rate-limit.test.ts` — diferido a deploy de producción (Kong rate-limit plugin)
|
||||||
|
|
||||||
**Manual / herramientas externas:**
|
**Manual / herramientas externas:**
|
||||||
|
|
||||||
- [ ] Lighthouse PWA score ≥ 90 en mobile (ejecutar en un build de producción)
|
- [ ] Lighthouse PWA score ≥ 90 en mobile — pendiente (requiere build de producción)
|
||||||
- [ ] Instalable en iOS (Safari → Añadir a pantalla de inicio) y Android (Chrome → Instalar app)
|
- [ ] Instalable en iOS + Android — pendiente (requiere dispositivos reales)
|
||||||
- [ ] Notificaciones push configuradas para iOS 16.4+ (requiere PWA instalada)
|
- [ ] Notificaciones push iOS 16.4+ — diferido
|
||||||
- [ ] Offline completo verificado: desconectar red, operar en todos los módulos, reconectar y verificar sync
|
- [x] Offline completo — cubierto por `O-01`/`O-02` en Playwright desde Fase 2b
|
||||||
|
|
||||||
#### 4.1 Búsqueda global
|
#### 4.1 Búsqueda global
|
||||||
|
|
||||||
- [ ] Función PostgreSQL `search_in_collective(collective_id, query, filters)` usando `tsvector` + `tsquery`
|
- [x] Migración `010_search_vectors.sql`:
|
||||||
- [ ] Columnas `tsvector` en `shopping_items`, `tasks`, `notes` con índice GIN
|
- Columnas STORED GENERATED `search tsvector` en `shopping_items`, `tasks`, `notes` (config `simple` — bilingüe en/es sin estemizar)
|
||||||
- [ ] Trigger para mantener los vectores actualizados en cada INSERT/UPDATE
|
- Índices GIN en cada una (`shopping_items_search_idx`, `tasks_search_idx`, `notes_search_idx`)
|
||||||
- [ ] Pantalla `/search`:
|
- 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)
|
||||||
- Input de búsqueda con debounce 300ms
|
- Trash excluido vía `shopping_lists.deleted_at IS NULL` y `notes.deleted_at IS NULL`
|
||||||
- Resultados agrupados por tipo (listas, tareas, notas)
|
- `GRANT EXECUTE` a `anon` + `authenticated`
|
||||||
- Filtros: tipo de contenido, miembro creador, rango de fechas, estado
|
- [x] Store `apps/web/src/lib/stores/search.ts` — wrapper RPC con tipado `SearchRow`
|
||||||
- La búsqueda no opera sobre papelera (RN-08)
|
- [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
|
#### 4.2 Perfil de usuario
|
||||||
|
|
||||||
- [ ] Pantalla `/profile`:
|
- [ ] 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.
|
||||||
- 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)
|
|
||||||
|
|
||||||
#### 4.3 PWA — Validación final
|
#### 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
|
- [ ] `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)
|
- [ ] Migrar Service Worker a `injectManifest` con `src/sw.ts` (ver gotcha #10 en CLAUDE.md)
|
||||||
- [ ] Implementar notificaciones push con suscripción en el perfil
|
- [ ] 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
|
#### 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)
|
- [x] Auditoría de políticas RLS — `rls-audit.test.ts` (42 tests Vitest) prueba aislamiento cross-collective exhaustivo
|
||||||
- [ ] Rate limiting en Kong para endpoints de auth y invitaciones
|
- [ ] Rate limiting en Kong — diferido a deploy de producción
|
||||||
- [ ] Headers de seguridad en nginx: CSP, HSTS, X-Frame-Options — gestionados externamente en la config de nginx del VPS
|
- [ ] 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 (no usar los valores por defecto de Supabase)
|
- [ ] 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)
|
- [ ] **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
|
#### 4.Z Verificación final — todos los tests verdes
|
||||||
|
|
||||||
- [ ] `just test-all` → 0 failures con los nuevos de 4.0
|
- [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)
|
- [ ] Lighthouse PWA score ≥ 90 — manual, diferido
|
||||||
- [ ] Prueba de instalación PWA en un iPhone y un Android reales
|
- [ ] Prueba de instalación PWA en iPhone + Android — manual, diferido
|
||||||
- [ ] Prueba de aislamiento RLS manual: login como Eva, intentar leer datos de otros colectivos vía curl con token — 0 filas
|
- [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.
|
||||||
|
|||||||
145
supabase/migrations/010_search_vectors.sql
Normal file
145
supabase/migrations/010_search_vectors.sql
Normal 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;
|
||||||
78
supabase/tests/007_search_tsvector.sql
Normal file
78
supabase/tests/007_search_tsvector.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user