SRC-10: gespeicherte suchanfragen
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-20 01:08:00 +02:00
1 parent de717c7bba
commit 437b0e70dc
12 files changed
+914

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
package savedsearch
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
)
var (
ErrNotFound = errors.New("savedsearch: nicht gefunden")
ErrInvalid = errors.New("savedsearch: ungueltige eingabe")
ErrConflict = errors.New("savedsearch: name bereits vergeben")
)
const (
maxNameLength = 120
maxQueryLength = 2000
)
var transientParams = []string{"caller", "limit", "offset"}
type Search struct {
ID string `json:"id"`
OwnerID string `json:"owner_id"`
Name string `json:"name"`
Query string `json:"query"`
Shared bool `json:"shared"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const columns = `id, owner_id, name, query, shared, created_at, updated_at`
func scan(row pgx.Row) (Search, error) {
var s Search
err := row.Scan(&s.ID, &s.OwnerID, &s.Name, &s.Query, &s.Shared, &s.CreatedAt, &s.UpdatedAt)
return s, err
}
func normalize(name, query string) (string, string, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > maxNameLength {
return "", "", fmt.Errorf("%w: name muss 1-%d zeichen lang sein", ErrInvalid, maxNameLength)
}
values, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
if err != nil {
return "", "", fmt.Errorf("%w: suchabfrage nicht lesbar", ErrInvalid)
}
for _, p := range transientParams {
values.Del(p)
}
if strings.TrimSpace(values.Get("q")) == "" {
return "", "", fmt.Errorf("%w: suchabfrage ohne suchbegriff", ErrInvalid)
}
encoded := values.Encode()
if len(encoded) > maxQueryLength {
return "", "", fmt.Errorf("%w: suchabfrage zu lang", ErrInvalid)
}
return name, encoded, nil
}
func mapWriteError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505":
return ErrConflict
case "23503", "22P02":
return fmt.Errorf("%w: unbekannter nutzer", ErrInvalid)
}
}
return err
}
func Create(ctx context.Context, pool *pgxpool.Pool, ownerID, name, query string, shared bool) (Search, error) {
name, query, err := normalize(name, query)
if err != nil {
return Search{}, err
}
out, err := scan(pool.QueryRow(ctx, `
INSERT INTO saved_searches (owner_id, name, query, shared) VALUES ($1, $2, $3, $4)
RETURNING `+columns, ownerID, name, query, shared))
if err != nil {
return Search{}, mapWriteError(fmt.Errorf("savedsearch: anlegen: %w", err))
}
return out, audittrail.Record(ctx, pool, ownerID, "saved_search_created", "saved_search", out.ID,
map[string]any{"name": out.Name, "shared": out.Shared})
}
func Update(ctx context.Context, pool *pgxpool.Pool, ownerID, id, name, query string, shared bool) (Search, error) {
name, query, err := normalize(name, query)
if err != nil {
return Search{}, err
}
out, err := scan(pool.QueryRow(ctx, `
UPDATE saved_searches SET name = $3, query = $4, shared = $5, updated_at = now()
WHERE id = $1 AND owner_id = $2 RETURNING `+columns, id, ownerID, name, query, shared))
if errors.Is(err, pgx.ErrNoRows) {
return Search{}, ErrNotFound
}
if err != nil {
return Search{}, mapWriteError(fmt.Errorf("savedsearch: aendern: %w", err))
}
return out, audittrail.Record(ctx, pool, ownerID, "saved_search_updated", "saved_search", out.ID,
map[string]any{"name": out.Name, "shared": out.Shared})
}
func Delete(ctx context.Context, pool *pgxpool.Pool, ownerID, id string) error {
tag, err := pool.Exec(ctx, `DELETE FROM saved_searches WHERE id = $1 AND owner_id = $2`, id, ownerID)
if err != nil {
return mapWriteError(fmt.Errorf("savedsearch: loeschen: %w", err))
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return audittrail.Record(ctx, pool, ownerID, "saved_search_deleted", "saved_search", id, nil)
}
func Get(ctx context.Context, pool *pgxpool.Pool, callerID, id string) (Search, error) {
out, err := scan(pool.QueryRow(ctx, `
SELECT `+columns+` FROM saved_searches WHERE id = $1 AND (owner_id = $2 OR shared)`, id, callerID))
if errors.Is(err, pgx.ErrNoRows) {
return Search{}, ErrNotFound
}
if err != nil {
return Search{}, mapWriteError(fmt.Errorf("savedsearch: lesen: %w", err))
}
return out, nil
}
func List(ctx context.Context, pool *pgxpool.Pool, callerID string) ([]Search, error) {
rows, err := pool.Query(ctx, `
SELECT `+columns+` FROM saved_searches WHERE owner_id = $1 OR shared
ORDER BY (owner_id = $1) DESC, lower(name), id`, callerID)
if err != nil {
return nil, mapWriteError(fmt.Errorf("savedsearch: auflisten: %w", err))
}
defer rows.Close()
out := []Search{}
for rows.Next() {
s, err := scan(rows)
if err != nil {
return nil, fmt.Errorf("savedsearch: lesen: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
@@ -0,0 +1,122 @@
package savedsearch
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
)
func newUser(t *testing.T, e *testenv.Env) string {
t.Helper()
var id string
err := e.Pool.QueryRow(context.Background(), `INSERT INTO users (email, name) VALUES ($1, 'Zweit') RETURNING id`,
fmt.Sprintf("savedsearch-%d@example.test", time.Now().UnixNano())).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func TestCreateNamesAndRestoresQuery(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
s, err := Create(ctx, e.Pool, e.UserID, " Offene Rechnungen ", "q=rechnung&sort=updated&caller=x&offset=40&limit=5", false)
if err != nil {
t.Fatal(err)
}
if s.Name != "Offene Rechnungen" || s.Query != "q=rechnung&sort=updated" {
t.Errorf("gespeichert: %+v", s)
}
got, err := Get(ctx, e.Pool, e.UserID, s.ID)
if err != nil || got.Query != s.Query || got.Name != s.Name {
t.Fatalf("wiederherstellen: %+v %v", got, err)
}
if _, err := Create(ctx, e.Pool, e.UserID, "offene rechnungen", "q=x", false); !errors.Is(err, ErrConflict) {
t.Errorf("doppelter name: %v", err)
}
}
func TestValidation(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
for name, c := range map[string][2]string{
"kein name": {"", "q=x"},
"nur blanks": {" ", "q=x"},
"kein q": {"n", "sort=updated"},
"leeres q": {"n", "q=%20"},
"zu lang": {"n", "q=" + strings.Repeat("a", 2100)},
"name lang": {strings.Repeat("n", 121), "q=x"},
} {
if _, err := Create(ctx, e.Pool, e.UserID, c[0], c[1], false); !errors.Is(err, ErrInvalid) {
t.Errorf("%s: %v", name, err)
}
}
if _, err := Create(ctx, e.Pool, "kein-uuid", "n", "q=x", false); !errors.Is(err, ErrInvalid) {
t.Errorf("unbekannter nutzer: %v", err)
}
}
func TestSharedSearchVisibleToOthersPrivateNot(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
other := newUser(t, e)
shared, _ := Create(ctx, e.Pool, e.UserID, "Team", "q=vertrag", true)
private, _ := Create(ctx, e.Pool, e.UserID, "Privat", "q=geheim", false)
list, err := List(ctx, e.Pool, other)
if err != nil || len(list) != 1 || list[0].ID != shared.ID {
t.Fatalf("fremde liste: %+v %v", list, err)
}
if _, err := Get(ctx, e.Pool, other, private.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("private suche fuer fremde lesbar: %v", err)
}
if _, err := Get(ctx, e.Pool, other, shared.ID); err != nil {
t.Errorf("geteilte suche: %v", err)
}
if _, err := Update(ctx, e.Pool, other, shared.ID, "x", "q=x", false); !errors.Is(err, ErrNotFound) {
t.Errorf("fremde aenderung: %v", err)
}
mine, _ := List(ctx, e.Pool, e.UserID)
if len(mine) != 2 {
t.Errorf("eigene liste = %d", len(mine))
}
}
func TestDeleteDoesNotAffectOthers(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
other := newUser(t, e)
shared, _ := Create(ctx, e.Pool, e.UserID, "Gemeinsam", "q=a", true)
theirs, _ := Create(ctx, e.Pool, other, "Gemeinsam", "q=b", false)
if err := Delete(ctx, e.Pool, other, shared.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("fremdes loeschen: %v", err)
}
if err := Delete(ctx, e.Pool, e.UserID, shared.ID); err != nil {
t.Fatal(err)
}
list, _ := List(ctx, e.Pool, other)
if len(list) != 1 || list[0].ID != theirs.ID {
t.Errorf("andere nutzer beeintraechtigt: %+v", list)
}
}
func TestAuditRecorded(t *testing.T) {
e := testenv.New(t)
ctx := context.Background()
s, _ := Create(ctx, e.Pool, e.UserID, "A", "q=a", false)
_, _ = Update(ctx, e.Pool, e.UserID, s.ID, "B", "q=a", true)
_ = Delete(ctx, e.Pool, e.UserID, s.ID)
var n int
if err := e.Pool.QueryRow(ctx, `SELECT count(*) FROM audit_log WHERE object_type = 'saved_search' AND object_reference = $1`, s.ID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("audit-eintraege = %d, want 3", n)
}
}
+165
View File
@@ -0,0 +1,165 @@
package savedsearchapi
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/savedsearch"
)
type Server struct {
pool *pgxpool.Pool
search http.Handler
}
// search ist der Handler von GET /search; Aufrufe laufen mit der Identitaet
// der aufrufenden Person, nie der des Besitzers.
func NewServer(pool *pgxpool.Pool, search http.Handler) *Server {
return &Server{pool: pool, search: search}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /saved-searches", s.handleList)
mux.HandleFunc("POST /saved-searches", s.handleCreate)
mux.HandleFunc("PUT /saved-searches/{id}", s.handleUpdate)
mux.HandleFunc("DELETE /saved-searches/{id}", s.handleDelete)
mux.HandleFunc("GET /saved-searches/{id}/results", s.handleResults)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, savedsearch.ErrInvalid):
status = http.StatusBadRequest
case errors.Is(err, savedsearch.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, savedsearch.ErrConflict):
status = http.StatusConflict
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func caller(w http.ResponseWriter, r *http.Request) (string, bool) {
c := strings.TrimSpace(r.URL.Query().Get("caller"))
if c == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "fehlender pflicht-parameter \"caller\""})
}
return c, c != ""
}
type input struct {
Name string `json:"name"`
Query string `json:"query"`
Shared bool `json:"shared"`
}
func decode(w http.ResponseWriter, r *http.Request) (input, bool) {
var in input
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungueltiger request-body"})
return in, false
}
return in, true
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
c, ok := caller(w, r)
if !ok {
return
}
out, err := savedsearch.List(r.Context(), s.pool, c)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
c, ok := caller(w, r)
if !ok {
return
}
in, ok := decode(w, r)
if !ok {
return
}
out, err := savedsearch.Create(r.Context(), s.pool, c, in.Name, in.Query, in.Shared)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
c, ok := caller(w, r)
if !ok {
return
}
in, ok := decode(w, r)
if !ok {
return
}
out, err := savedsearch.Update(r.Context(), s.pool, c, r.PathValue("id"), in.Name, in.Query, in.Shared)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
c, ok := caller(w, r)
if !ok {
return
}
if err := savedsearch.Delete(r.Context(), s.pool, c, r.PathValue("id")); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
c, ok := caller(w, r)
if !ok {
return
}
saved, err := savedsearch.Get(r.Context(), s.pool, c, r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
values, err := url.ParseQuery(saved.Query)
if err != nil {
writeError(w, savedsearch.ErrInvalid)
return
}
values.Set("caller", c)
for _, p := range []string{"limit", "offset"} {
if v := r.URL.Query().Get(p); v != "" {
values.Set(p, v)
}
}
req := httptest.NewRequest(http.MethodGet, "/search?"+values.Encode(), nil).WithContext(r.Context())
rec := httptest.NewRecorder()
s.search.ServeHTTP(rec, req)
for k, v := range rec.Header() {
w.Header()[k] = v
}
w.WriteHeader(rec.Code)
_, _ = w.Write(rec.Body.Bytes())
}
+204
View File
@@ -0,0 +1,204 @@
package savedsearchapi
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/savedsearch"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testlock"
)
type denyList map[string]bool
func (d denyList) CanAccessDocument(_ context.Context, _, documentID string) (bool, error) {
return !d[documentID], nil
}
type fixture struct {
srv *httptest.Server
index *sql.DB
env *testenv.Env
}
func setup(t *testing.T, perms searchapi.PermissionChecker) *fixture {
t.Helper()
addr := os.Getenv("TEST_MANTICORE_ADDR")
if addr == "" {
t.Skip("TEST_MANTICORE_ADDR nicht gesetzt, Manticore-Integrationstest uebersprungen")
}
e := testenv.New(t)
testlock.Acquire(t, "manticore")
db, err := searchindex.Open(addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := searchindex.EnsureSchema(context.Background(), db); err != nil {
t.Fatal(err)
}
searchMux := http.NewServeMux()
searchapi.NewServer(db, perms).RegisterRoutes(searchMux)
mux := http.NewServeMux()
NewServer(e.Pool, searchMux).RegisterRoutes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &fixture{srv: srv, index: db, env: e}
}
var idSeq = time.Now().UnixNano() & 0x3FFFFFFFFFFFFFFF
func (f *fixture) indexDoc(t *testing.T, title, tok, owner string) searchindex.Doc {
t.Helper()
idSeq++
d := searchindex.Doc{ID: idSeq, DocumentID: fmt.Sprintf("doc-%d", idSeq), Title: title, Body: tok, CreatedBy: owner,
CreatedAtSec: time.Now().Unix(), UpdatedAtSec: time.Now().Unix()}
if err := searchindex.Upsert(context.Background(), f.index, d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = searchindex.Delete(context.Background(), f.index, d.ID) })
return d
}
func (f *fixture) newUser(t *testing.T) string {
t.Helper()
var id string
if err := f.env.Pool.QueryRow(context.Background(), `INSERT INTO users (email, name) VALUES ($1, 'U') RETURNING id`,
fmt.Sprintf("ssapi-%d@example.test", time.Now().UnixNano())).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
func (f *fixture) do(t *testing.T, method, path string, body any) (int, []byte) {
t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, f.srv.URL+path, rd)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.Bytes()
}
type results struct {
Total int `json:"total"`
Hits []struct {
DocumentID string `json:"document_id"`
} `json:"hits"`
}
func (f *fixture) run(t *testing.T, caller, id string) results {
t.Helper()
status, body := f.do(t, "GET", "/saved-searches/"+id+"/results?caller="+caller, nil)
if status != 200 {
t.Fatalf("results status = %d: %s", status, body)
}
var out results
_ = json.Unmarshal(body, &out)
return out
}
func (f *fixture) save(t *testing.T, caller, name, query string, shared bool) savedsearch.Search {
t.Helper()
status, body := f.do(t, "POST", "/saved-searches?caller="+caller, map[string]any{"name": name, "query": query, "shared": shared})
if status != 201 {
t.Fatalf("create status = %d: %s", status, body)
}
var s savedsearch.Search
_ = json.Unmarshal(body, &s)
return s
}
func tokenFor() string { return fmt.Sprintf("zq%dx", time.Now().UnixNano()) }
func TestResultsAreDeterminedFreshOnEveryCall(t *testing.T) {
f := setup(t, denyList{})
tok := tokenFor()
owner := f.env.UserID
f.indexDoc(t, "Erstes", tok, owner)
s := f.save(t, owner, "Frisch", "q="+tok, false)
if got := f.run(t, owner, s.ID); got.Total != 1 {
t.Fatalf("vorher total = %d", got.Total)
}
f.indexDoc(t, "Zweites", tok, owner)
if got := f.run(t, owner, s.ID); got.Total != 2 {
t.Errorf("nach datenaenderung total = %d, want 2", got.Total)
}
}
func TestSharedSearchShowsEachCallerOnlyAllowedDocuments(t *testing.T) {
tok := tokenFor()
deny := denyList{}
f := setup(t, deny)
owner := f.env.UserID
other := f.newUser(t)
ownDoc := f.indexDoc(t, "Eigenes", tok, owner)
blocked := f.indexDoc(t, "Gesperrt", tok, owner)
foreign := f.indexDoc(t, "Fremd", tok, other)
deny[blocked.DocumentID] = true
s := f.save(t, owner, "Team", "q="+tok, true)
ownerRes := f.run(t, owner, s.ID)
if len(ownerRes.Hits) != 1 || ownerRes.Hits[0].DocumentID != ownDoc.DocumentID {
t.Errorf("besitzer sieht %+v", ownerRes.Hits)
}
otherRes := f.run(t, other, s.ID)
if len(otherRes.Hits) != 1 || otherRes.Hits[0].DocumentID != foreign.DocumentID {
t.Errorf("zweite person sieht %+v", otherRes.Hits)
}
private := f.save(t, owner, "Privat", "q="+tok, false)
if status, _ := f.do(t, "GET", "/saved-searches/"+private.ID+"/results?caller="+other, nil); status != 404 {
t.Errorf("private suche fuer fremde: status %d", status)
}
}
func TestHTTPLifecycleAndOwnerOnlyDelete(t *testing.T) {
f := setup(t, denyList{})
owner := f.env.UserID
other := f.newUser(t)
s := f.save(t, owner, "Suche", "q=abc", true)
status, body := f.do(t, "GET", "/saved-searches?caller="+other, nil)
var list []savedsearch.Search
_ = json.Unmarshal(body, &list)
if status != 200 || len(list) != 1 {
t.Fatalf("liste: %d %s", status, body)
}
if status, _ := f.do(t, "DELETE", "/saved-searches/"+s.ID+"?caller="+other, nil); status != 404 {
t.Errorf("fremdes loeschen: %d", status)
}
if status, _ := f.do(t, "PUT", "/saved-searches/"+s.ID+"?caller="+owner, map[string]any{"name": "Neu", "query": "q=abc", "shared": false}); status != 200 {
t.Errorf("umbenennen: %d", status)
}
if status, _ := f.do(t, "DELETE", "/saved-searches/"+s.ID+"?caller="+owner, nil); status != 204 {
t.Errorf("loeschen: %d", status)
}
if status, _ := f.do(t, "GET", "/saved-searches", nil); status != 400 {
t.Errorf("ohne caller: %d", status)
}
if status, _ := f.do(t, "POST", "/saved-searches?caller="+owner, map[string]any{"name": "", "query": "q=x"}); status != 400 {
t.Errorf("leerer name: %d", status)
}
}
@@ -0,0 +1 @@
DROP TABLE saved_searches;
@@ -0,0 +1,11 @@
CREATE TABLE saved_searches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 120),
query TEXT NOT NULL CHECK (length(query) BETWEEN 1 AND 2000),
shared BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_saved_searches_owner_name ON saved_searches (owner_id, lower(name));
CREATE INDEX idx_saved_searches_shared ON saved_searches (shared) WHERE shared;
+3
View File
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useI18n } from "@nexarch/shl";
import "../../lib/messages";
import { SavedSearches } from "../../components/SavedSearches";
import {
MAX_TERM_LENGTH,
PAGE_SIZE,
@@ -94,6 +95,8 @@ function SearchView() {
</select>
</form>
<SavedSearches currentQuery={key} />
{truncated && <p role="status">{t("dms.search.truncated").replace("{max}", String(MAX_TERM_LENGTH))}</p>}
{error && <p role="alert">{error}</p>}
{loading && <p role="status">{t("dms.search.loading")}</p>}
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { axe } from "jest-axe";
import { I18nProvider } from "@nexarch/shl";
import { SavedSearches } from "./SavedSearches";
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_SEARCH_API_URL: "http://backend.test", NEXT_PUBLIC_USER_ID: "me" };
});
afterEach(() => {
cleanup();
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
});
const list = [
{ id: "s1", owner_id: "me", name: "Meine Rechnungen", query: "q=rechnung&sort=updated", shared: false },
{ id: "s2", owner_id: "other", name: "Team Vertraege", query: "q=vertrag", shared: true },
];
const ui = (q = "q=neu") => render(<I18nProvider initialLocale="de"><SavedSearches currentQuery={q} /></I18nProvider>);
it("listet eigene und geteilte Suchen und stellt sie per Link wieder her (AK1, AK2)", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
ui();
const own = await screen.findByRole("link", { name: "Meine Rechnungen" });
expect(own.getAttribute("href")).toBe("/search?q=rechnung&sort=updated");
expect(screen.getByRole("link", { name: "Team Vertraege" })).toBeTruthy();
expect(screen.getByText("(geteilt)")).toBeTruthy();
});
it("bietet Loeschen nur fuer eigene Suchen an", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
ui();
await screen.findByRole("link", { name: "Meine Rechnungen" });
expect(screen.getAllByRole("button", { name: /Löschen:/ })).toHaveLength(1);
});
it("speichert die aktuelle Suche mit Name und Teilen-Flag", async () => {
const f = vi.fn().mockImplementation((_u: string, init?: RequestInit) =>
Promise.resolve(new Response(init?.method === "POST" ? "{}" : "[]", { status: init?.method === "POST" ? 201 : 200 })));
vi.stubGlobal("fetch", f);
ui("q=neu&sort=updated");
fireEvent.change(await screen.findByLabelText("Name der Suche"), { target: { value: "Neu" } });
fireEvent.click(screen.getByLabelText("Mit dem Team teilen"));
fireEvent.click(screen.getByRole("button", { name: "Aktuelle Suche speichern" }));
await waitFor(() => expect(f.mock.calls.some((c) => c[1]?.method === "POST")).toBe(true));
const post = f.mock.calls.find((c) => c[1]?.method === "POST")!;
expect(JSON.parse(post[1].body)).toEqual({ name: "Neu", query: "q=neu&sort=updated", shared: true });
});
it("hat keine axe-Verstoesse", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
const { container } = ui();
await screen.findByRole("link", { name: "Meine Rechnungen" });
expect((await axe(container)).violations).toEqual([]);
});
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { useI18n } from "@nexarch/shl";
import "../lib/messages";
import { caller } from "../lib/caller";
import { SavedSearch, createSavedSearch, deleteSavedSearch, fetchSavedSearches, savedSearchHref } from "../lib/savedSearchApi";
export function SavedSearches({ currentQuery }: { currentQuery: string }) {
const { t } = useI18n();
const [items, setItems] = useState<SavedSearch[]>([]);
const [name, setName] = useState("");
const [shared, setShared] = useState(false);
const [error, setError] = useState<string | null>(null);
function reload() {
fetchSavedSearches()
.then(setItems)
.catch(() => setError(t("dms.saved.loadError")));
}
useEffect(reload, []);
async function save(e: React.FormEvent) {
e.preventDefault();
setError(null);
try {
await createSavedSearch(name.trim(), currentQuery, shared);
setName("");
setShared(false);
reload();
} catch {
setError(t("dms.saved.saveError"));
}
}
async function remove(id: string) {
setError(null);
try {
await deleteSavedSearch(id);
reload();
} catch {
setError(t("dms.saved.deleteError"));
}
}
return (
<section aria-labelledby="saved-searches-title" style={{ marginBottom: 16 }}>
<h2 id="saved-searches-title">{t("dms.saved.title")}</h2>
{error && <p role="alert">{error}</p>}
{currentQuery && (
<form onSubmit={save} style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 8 }}>
<label htmlFor="saved-name" className="shl-visually-hidden">
{t("dms.saved.name")}
</label>
<input
id="saved-name"
value={name}
required
maxLength={120}
placeholder={t("dms.saved.name")}
onChange={(e) => setName(e.target.value)}
style={{ flex: "1 1 200px", minWidth: 0, padding: 8, minHeight: 44 }}
/>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input type="checkbox" checked={shared} onChange={(e) => setShared(e.target.checked)} />
{t("dms.saved.share")}
</label>
<button type="submit" style={{ minHeight: 44 }}>{t("dms.saved.save")}</button>
</form>
)}
{items.length === 0 ? (
<p>{t("dms.saved.empty")}</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{items.map((s) => (
<li key={s.id} style={{ display: "flex", gap: 8, alignItems: "center", padding: "4px 0" }}>
<a href={savedSearchHref(s)} style={{ overflowWrap: "anywhere" }}>
{s.name}
</a>
{s.shared && <span>({t("dms.saved.shared")})</span>}
{s.owner_id === caller() && (
<button type="button" style={{ minHeight: 44 }} onClick={() => remove(s.id)} aria-label={`${t("dms.saved.delete")}: ${s.name}`}>
{t("dms.saved.delete")}
</button>
)}
</li>
))}
</ul>
)}
</section>
);
}
+22
View File
@@ -127,6 +127,17 @@ registerMessages("de", {
"dms.search.paging": "Seitenwechsel der Trefferliste",
"dms.search.prev": "Zurück",
"dms.search.next": "Weiter",
"dms.saved.title": "Gespeicherte Suchen",
"dms.saved.empty": "Noch keine gespeicherten Suchen.",
"dms.saved.name": "Name der Suche",
"dms.saved.share": "Mit dem Team teilen",
"dms.saved.save": "Aktuelle Suche speichern",
"dms.saved.open": "Aufrufen",
"dms.saved.delete": "Löschen",
"dms.saved.shared": "geteilt",
"dms.saved.loadError": "Gespeicherte Suchen konnten nicht geladen werden.",
"dms.saved.saveError": "Die Suche konnte nicht gespeichert werden.",
"dms.saved.deleteError": "Die Suche konnte nicht gelöscht werden.",
});
registerMessages("en", {
@@ -251,4 +262,15 @@ registerMessages("en", {
"dms.search.paging": "Result list paging",
"dms.search.prev": "Previous",
"dms.search.next": "Next",
"dms.saved.title": "Saved searches",
"dms.saved.empty": "No saved searches yet.",
"dms.saved.name": "Search name",
"dms.saved.share": "Share with the team",
"dms.saved.save": "Save current search",
"dms.saved.open": "Run",
"dms.saved.delete": "Delete",
"dms.saved.shared": "shared",
"dms.saved.loadError": "Saved searches could not be loaded.",
"dms.saved.saveError": "The search could not be saved.",
"dms.saved.deleteError": "The search could not be deleted.",
});
@@ -0,0 +1,37 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createSavedSearch, deleteSavedSearch, fetchSavedSearches, savedSearchHref } from "./savedSearchApi";
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_SEARCH_API_URL: "http://backend.test", NEXT_PUBLIC_USER_ID: "user-1" };
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
});
it("liest die Liste mit caller", async () => {
const f = vi.fn().mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", f);
expect(await fetchSavedSearches()).toEqual([]);
expect(f.mock.calls[0][0]).toBe("http://backend.test/saved-searches?caller=user-1");
});
it("legt eine Suche mit Name, Abfrage und Teilen-Flag an", async () => {
const f = vi.fn().mockResolvedValue(new Response("{}", { status: 201 }));
vi.stubGlobal("fetch", f);
await createSavedSearch("Rechnungen", "q=rechnung", true);
expect(f.mock.calls[0][1].method).toBe("POST");
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ name: "Rechnungen", query: "q=rechnung", shared: true });
});
it("loescht per DELETE und meldet Fehler", async () => {
const f = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
vi.stubGlobal("fetch", f);
await expect(deleteSavedSearch("s1")).rejects.toThrow("404");
expect(f.mock.calls[0][0]).toBe("http://backend.test/saved-searches/s1?caller=user-1");
});
it("stellt die Suche ueber die Such-URL wieder her (AK1)", () => {
expect(savedSearchHref({ query: "q=rechnung&sort=updated" })).toBe("/search?q=rechnung&sort=updated");
});
+43
View File
@@ -0,0 +1,43 @@
import { withCaller } from "./caller";
export interface SavedSearch {
id: string;
owner_id: string;
name: string;
query: string;
shared: boolean;
}
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_SEARCH_API_URL;
if (!base) {
throw new Error("NEXT_PUBLIC_SEARCH_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base;
}
export async function fetchSavedSearches(): Promise<SavedSearch[]> {
const res = await fetch(withCaller(`${apiBase()}/saved-searches`));
if (!res.ok) throw new Error(`Gespeicherte Suchen konnten nicht geladen werden (${res.status})`);
const body = await res.json();
return Array.isArray(body) ? body : [];
}
export async function createSavedSearch(name: string, query: string, shared: boolean): Promise<SavedSearch> {
const res = await fetch(withCaller(`${apiBase()}/saved-searches`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, query, shared }),
});
if (!res.ok) throw new Error(`Suche konnte nicht gespeichert werden (${res.status})`);
return res.json();
}
export async function deleteSavedSearch(id: string): Promise<void> {
const res = await fetch(withCaller(`${apiBase()}/saved-searches/${encodeURIComponent(id)}`), { method: "DELETE" });
if (!res.ok) throw new Error(`Suche konnte nicht gelöscht werden (${res.status})`);
}
export function savedSearchHref(s: Pick<SavedSearch, "query">): string {
return `/search?${s.query}`;
}