Files
paliad/internal/services/pin_service_test.go
m a5f7b5009b feat(t-paliad-149) PR1 step 2/3: frontend rewrite — chips + pin star + last-view restore
frontend/src/projects.tsx — strip the legacy 3-select toolbar; replace with
search input + view-mode segment-control (Tree | Liste) + chip filter row
(Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen). Tree
container is the default visible mount; flat-table hidden until view mode
toggles.

frontend/src/client/projects.ts — orchestrator. Owns chip + search + view-
mode state. Last-viewed restore from sessionStorage (Q1 lock-in), URL params
override on load, syncURL on every state change. Debounced search (250ms).
Multi-select panels via <details> for status/type. Delegates rendering to
project-tree.ts (tree mode) or projects-flat.ts (flat mode).

frontend/src/client/projects-flat.ts (NEW) — extracted table render from the
old projects.ts so the orchestrator can mount/unmount cleanly.

frontend/src/client/project-tree.ts — extends ProjectTreeNode shape with
pinned, inherited_visibility, match_kind, *_subtree fields. Renders pin
star button (always-visible per design §4.6 — touch-friendly), greyed-
ancestor opacity for InheritedVisibility=true, lime backdrop on
match_kind=self. Pin click does optimistic toggle + POST/DELETE
/api/projects/{id}/pin then invalidates the tree cache.

frontend/src/styles/global.css — toolbar + chips + pin star + greyed-
ancestor + match highlighting. ~200 LoC appended.

frontend/src/client/i18n.ts — 29 new keys DE+EN under projects.toolbar.*,
projects.chip.*, projects.tree.deadlines.*, projects.tree.pin/unpin,
projects.search.match.*, projects.empty.filtered.action.

internal/services/pin_service_test.go (NEW) — live-DB tests for PinService
(pin/unpin/idempotent/owner-scope/visibility-gate) + 2 BuildTreeWithOptions
cases (PinnedSet surfaces, ScopeMine greys ancestors). Skips without
TEST_DATABASE_URL; pure-Go path runs clean.

Frontend bun build clean. go build / vet / test (short) clean.
2026-05-07 22:29:39 +02:00

322 lines
9.4 KiB
Go

package services
// Live-DB tests for PinService and the BuildTreeWithOptions chip branches.
// Skipped when TEST_DATABASE_URL is unset.
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
type pinTestEnv struct {
t *testing.T
pool *sqlx.DB
pin *PinService
projects *ProjectService
userID uuid.UUID
otherUserID uuid.UUID
clientID uuid.UUID // root, directly-staffed
caseID uuid.UUID // child of client, directly-staffed
otherID uuid.UUID // root, NOT directly-staffed (visible only to admin)
cleanup func()
}
func setupPinTest(t *testing.T) *pinTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
userID := uuid.New() // standard user
otherUserID := uuid.New() // separate user (used as creator of the "other" project)
clientID := uuid.New()
caseID := uuid.New()
otherID := uuid.New()
cleanup := func() {
c := context.Background()
pool.ExecContext(c, `DELETE FROM paliad.user_pinned_projects WHERE user_id IN ($1, $2)`, userID, otherUserID)
for _, pid := range []uuid.UUID{caseID, clientID, otherID} {
pool.ExecContext(c, `DELETE FROM paliad.project_teams WHERE project_id = $1`, pid)
pool.ExecContext(c, `DELETE FROM paliad.project_events WHERE project_id = $1`, pid)
}
pool.ExecContext(c, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, clientID)
pool.ExecContext(c, `DELETE FROM paliad.projects WHERE id = $1`, otherID)
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, userID, otherUserID)
pool.ExecContext(c, `DELETE FROM auth.users WHERE id IN ($1, $2)`, userID, otherUserID)
}
cleanup()
for _, u := range []struct {
id uuid.UUID
email string
role string
}{
{userID, "pin-test-user@hlc.com", "standard"},
{otherUserID, "pin-test-other@hlc.com", "standard"},
} {
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`, u.id, u.email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Pin Test', 'munich', $3, 'de')`,
u.id, u.email, u.role); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
}
// Build a small tree owned by userID (so userID is staffed direct on
// clientID and caseID). otherID is owned by otherUserID — userID has
// no visibility into it.
for _, p := range []struct {
id uuid.UUID
typ string
parent *uuid.UUID
title string
creator uuid.UUID
staff uuid.UUID
}{
{clientID, "client", nil, "Pin Client", userID, userID},
{caseID, "case", &clientID, "Pin Case", userID, userID},
{otherID, "client", nil, "Other Client", otherUserID, otherUserID},
} {
var parent any
if p.parent != nil {
parent = *p.parent
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'active', $6)`,
p.id, p.typ, parent, p.id.String(), p.title, p.creator); err != nil {
t.Fatalf("seed paliad.projects %s: %v", p.id, err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', false, $2)`, p.id, p.staff); err != nil {
t.Fatalf("seed project_teams %s: %v", p.id, err)
}
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
pin := NewPinService(pool, projects)
return &pinTestEnv{
t: t,
pool: pool,
pin: pin,
projects: projects,
userID: userID,
otherUserID: otherUserID,
clientID: clientID,
caseID: caseID,
otherID: otherID,
cleanup: func() { cleanup(); pool.Close() },
}
}
func TestPinService_PinAndIsPinned(t *testing.T) {
env := setupPinTest(t)
defer env.cleanup()
ctx := context.Background()
// Initially not pinned.
if pinned, err := env.pin.IsPinned(ctx, env.userID, env.clientID); err != nil || pinned {
t.Fatalf("IsPinned before = (%v, %v); want (false, nil)", pinned, err)
}
// Pin succeeds and is idempotent.
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Pin: %v", err)
}
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Pin (idempotent): %v", err)
}
// IsPinned now true.
if pinned, err := env.pin.IsPinned(ctx, env.userID, env.clientID); err != nil || !pinned {
t.Fatalf("IsPinned after = (%v, %v); want (true, nil)", pinned, err)
}
// PinnedSet contains exactly one entry.
set, err := env.pin.PinnedSet(ctx, env.userID)
if err != nil {
t.Fatalf("PinnedSet: %v", err)
}
if len(set) != 1 {
t.Errorf("PinnedSet size = %d, want 1", len(set))
}
if _, ok := set[env.clientID]; !ok {
t.Errorf("PinnedSet missing clientID")
}
// ListPinned returns slice form.
ids, err := env.pin.ListPinned(ctx, env.userID)
if err != nil {
t.Fatalf("ListPinned: %v", err)
}
if len(ids) != 1 || ids[0] != env.clientID {
t.Errorf("ListPinned = %v, want [clientID]", ids)
}
}
func TestPinService_PinInvisible(t *testing.T) {
env := setupPinTest(t)
defer env.cleanup()
ctx := context.Background()
// userID cannot see otherID — Pin must return ErrNotVisible.
if err := env.pin.Pin(ctx, env.userID, env.otherID); !errors.Is(err, ErrNotVisible) {
t.Fatalf("Pin invisible = %v, want ErrNotVisible", err)
}
}
func TestPinService_UnpinIdempotent(t *testing.T) {
env := setupPinTest(t)
defer env.cleanup()
ctx := context.Background()
// Unpin without prior pin = no-op.
if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Unpin (never pinned): %v", err)
}
// Pin then unpin twice; no error.
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Pin: %v", err)
}
if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Unpin: %v", err)
}
if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Unpin (idempotent): %v", err)
}
if pinned, _ := env.pin.IsPinned(ctx, env.userID, env.clientID); pinned {
t.Errorf("IsPinned after double-unpin = true, want false")
}
}
func TestPinService_OwnerScope(t *testing.T) {
env := setupPinTest(t)
defer env.cleanup()
ctx := context.Background()
// User A pins. User B's PinnedSet must be empty (RLS / explicit user_id).
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Pin: %v", err)
}
otherSet, err := env.pin.PinnedSet(ctx, env.otherUserID)
if err != nil {
t.Fatalf("PinnedSet (other user): %v", err)
}
if len(otherSet) != 0 {
t.Errorf("other user PinnedSet size = %d, want 0", len(otherSet))
}
}
func TestBuildTreeWithOptions_PinnedSetSurfaces(t *testing.T) {
env := setupPinTest(t)
defer env.cleanup()
ctx := context.Background()
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
t.Fatalf("Pin: %v", err)
}
set, _ := env.pin.PinnedSet(ctx, env.userID)
tree, err := env.projects.BuildTreeWithOptions(ctx, env.userID, BuildTreeOptions{
PinnedSet: set,
IncludeSubtreeCounts: true,
})
if err != nil {
t.Fatalf("BuildTreeWithOptions: %v", err)
}
if len(tree) == 0 {
t.Fatalf("tree empty; want clientID at root")
}
var found *ProjectTreeNode
for _, r := range tree {
if r.ID == env.clientID {
found = r
break
}
}
if found == nil {
t.Fatalf("clientID not at root; tree=%+v", tree)
}
if !found.Pinned {
t.Errorf("clientID Pinned=false, want true")
}
// Case sub-node should NOT be pinned (we only pinned the client).
if len(found.Children) == 0 || found.Children[0].Pinned {
t.Errorf("child node should not be Pinned; got %+v", found.Children)
}
}
func TestBuildTreeWithOptions_ScopeMineGreysAncestors(t *testing.T) {
env := setupPinTest(t)
defer env.cleanup()
ctx := context.Background()
// Remove the direct staffing on clientID so caseID becomes the only
// directly-staffed project under the client. Then ScopeMine must keep
// clientID as a greyed ancestor (InheritedVisibility=true).
if _, err := env.pool.ExecContext(ctx,
`DELETE FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
env.clientID, env.userID); err != nil {
t.Fatalf("delete client staffing: %v", err)
}
tree, err := env.projects.BuildTreeWithOptions(ctx, env.userID, BuildTreeOptions{
Scope: ScopeMine,
})
if err != nil {
t.Fatalf("BuildTreeWithOptions ScopeMine: %v", err)
}
if len(tree) == 0 {
t.Fatalf("ScopeMine tree empty; want clientID greyed-ancestor")
}
var client *ProjectTreeNode
for _, r := range tree {
if r.ID == env.clientID {
client = r
break
}
}
if client == nil {
t.Fatalf("clientID missing from ScopeMine tree")
}
if !client.InheritedVisibility {
t.Errorf("clientID InheritedVisibility=false; want true (greyed ancestor)")
}
if len(client.Children) != 1 || client.Children[0].ID != env.caseID {
t.Fatalf("expected single child caseID; got %+v", client.Children)
}
if client.Children[0].InheritedVisibility {
t.Errorf("caseID is direct-staffed; should NOT have InheritedVisibility=true")
}
}