package db import ( "context" "errors" "testing" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) // mkProject is a convenience for CreateProject in tests. func mkProject(t *testing.T, s *Store, ctx context.Context, owner, name string) *Project { t.Helper() p, err := s.CreateProject(ctx, core.ProjectRef{Owner: owner, Name: name}) if err != nil { t.Fatalf("create project ~%s/+%s: %v", owner, name, err) } return p } func TestProjectLifecycle(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() ref := core.ProjectRef{Owner: "bigbes", Name: "tarantool"} p, err := s.CreateProject(ctx, ref) if err != nil { t.Fatalf("create project: %v", err) } if p.ID == 0 { t.Fatal("expected a non-zero project id") } if p.Created.IsZero() { t.Fatal("expected created to be set") } if p.Ref != ref { t.Fatalf("ref round-trip: got %v, want %v", p.Ref, ref) } // (owner, name) is unique. if _, err := s.CreateProject(ctx, ref); !errors.Is(err, ErrProjectExists) { t.Fatalf("duplicate project = %v, want ErrProjectExists", err) } // Same name under a different owner is fine. if _, err := s.CreateProject(ctx, core.ProjectRef{Owner: "someone", Name: "tarantool"}); err != nil { t.Fatalf("same name, different owner: %v", err) } // A project and a space may share a name: they are different namespaces, // addressed "~bigbes/+tarantool" and "~bigbes/tarantool". if _, err := s.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "tarantool"}); err != nil { t.Fatalf("space with a project's name: %v", err) } got, err := s.GetProject(ctx, ref) if err != nil { t.Fatalf("get project: %v", err) } if got.ID != p.ID || got.Ref != ref { t.Fatalf("get project returned %+v, want id=%d ref=%v", got, p.ID, ref) } if _, err := s.GetProject(ctx, core.ProjectRef{Owner: "bigbes", Name: "absent"}); !errors.Is(err, ErrNotFound) { t.Fatalf("missing project = %v, want ErrNotFound", err) } mkProject(t, s, ctx, "bigbes", "home-ops") projects, err := s.ListProjects(ctx) if err != nil { t.Fatalf("list projects: %v", err) } want := []string{"~bigbes/+home-ops", "~bigbes/+tarantool", "~someone/+tarantool"} if len(projects) != len(want) { t.Fatalf("expected %d projects, got %d", len(want), len(projects)) } for i, p := range projects { if p.Ref.String() != want[i] { t.Errorf("projects[%d] = %s, want %s", i, p.Ref, want[i]) } } if err := s.DeleteProject(ctx, p.ID); err != nil { t.Fatalf("delete project: %v", err) } if _, err := s.GetProject(ctx, ref); !errors.Is(err, ErrNotFound) { t.Fatalf("get after delete = %v, want ErrNotFound", err) } if err := s.DeleteProject(ctx, p.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("second delete = %v, want ErrNotFound", err) } } // A project is a saved filter: this is the query the index needs, and the one // every other project operation exists to serve. func TestProjectMembership(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() rfcs := mkSpace(t, s, ctx, "bigbes", "rfcs") notes := mkSpace(t, s, ctx, "bigbes", "notes") other := mkSpace(t, s, ctx, "bigbes", "unrelated") proj := mkProject(t, s, ctx, "bigbes", "tarantool") // A brand-new project selects nothing — not everything. spaces, err := s.ProjectSpaces(ctx, proj.ID) if err != nil { t.Fatalf("project spaces: %v", err) } if len(spaces) != 0 { t.Fatalf("a fresh project selects %d spaces, want 0", len(spaces)) } if err := s.AddProjectSpace(ctx, proj.ID, rfcs.ID); err != nil { t.Fatalf("add rfcs: %v", err) } if err := s.AddProjectSpace(ctx, proj.ID, notes.ID); err != nil { t.Fatalf("add notes: %v", err) } // Membership is a set: adding twice is not an error and adds nothing. if err := s.AddProjectSpace(ctx, proj.ID, rfcs.ID); err != nil { t.Fatalf("re-add rfcs: %v", err) } spaces, err = s.ProjectSpaces(ctx, proj.ID) if err != nil { t.Fatalf("project spaces: %v", err) } want := []string{"~bigbes/notes", "~bigbes/rfcs"} if len(spaces) != len(want) { t.Fatalf("project selects %d spaces, want %d", len(spaces), len(want)) } for i, sp := range spaces { if sp.Ref.String() != want[i] { t.Errorf("spaces[%d] = %s, want %s", i, sp.Ref, want[i]) } if sp.ID == 0 { t.Errorf("spaces[%d] has no id; the id set is the filter", i) } } // The other direction. projects, err := s.ProjectsBySpace(ctx, rfcs.ID) if err != nil { t.Fatalf("projects by space: %v", err) } if len(projects) != 1 || projects[0].ID != proj.ID { t.Fatalf("projects of ~bigbes/rfcs = %+v, want just %s", projects, proj.Ref) } projects, err = s.ProjectsBySpace(ctx, other.ID) if err != nil { t.Fatalf("projects by space: %v", err) } if len(projects) != 0 { t.Fatalf("a space in no project reports %d, want 0", len(projects)) } // A space in two projects is ordinary: projects are filters, not owners. second := mkProject(t, s, ctx, "bigbes", "all-rfcs") if err := s.AddProjectSpace(ctx, second.ID, rfcs.ID); err != nil { t.Fatalf("add rfcs to a second project: %v", err) } projects, err = s.ProjectsBySpace(ctx, rfcs.ID) if err != nil { t.Fatalf("projects by space: %v", err) } if len(projects) != 2 { t.Fatalf("~bigbes/rfcs is in %d projects, want 2", len(projects)) } if err := s.RemoveProjectSpace(ctx, proj.ID, notes.ID); err != nil { t.Fatalf("remove notes: %v", err) } // Removing what is not a member is reported, not silently accepted. if err := s.RemoveProjectSpace(ctx, proj.ID, notes.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("second remove = %v, want ErrNotFound", err) } spaces, err = s.ProjectSpaces(ctx, proj.ID) if err != nil { t.Fatalf("project spaces: %v", err) } if len(spaces) != 1 || spaces[0].ID != rfcs.ID { t.Fatalf("after removal the project selects %+v", spaces) } // The removed space still exists; only the filter term went. if _, err := s.GetSpaceByID(ctx, notes.ID); err != nil { t.Fatalf("removing a space from a project deleted the space: %v", err) } } // A filter term pointing at nothing would silently narrow every query made // through it, so the foreign keys are enforced and reported. func TestAddProjectSpaceRequiresBothRows(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() sp := mkSpace(t, s, ctx, "bigbes", "rfcs") proj := mkProject(t, s, ctx, "bigbes", "tarantool") if err := s.AddProjectSpace(ctx, 99999, sp.ID); !errors.Is(err, ErrNotFound) { t.Errorf("add to a missing project = %v, want ErrNotFound", err) } if err := s.AddProjectSpace(ctx, proj.ID, 99999); !errors.Is(err, ErrNotFound) { t.Errorf("add a missing space = %v, want ErrNotFound", err) } } // Both sides cascade, and each cascade stops at the membership row: deleting a // project deletes no content, and deleting a space leaves no dangling filter // term behind. func TestProjectMembershipCascades(t *testing.T) { s, pool, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() rfcs := mkSpace(t, s, ctx, "bigbes", "rfcs") notes := mkSpace(t, s, ctx, "bigbes", "notes") proj := mkProject(t, s, ctx, "bigbes", "tarantool") for _, sp := range []*Space{rfcs, notes} { if err := s.AddProjectSpace(ctx, proj.ID, sp.ID); err != nil { t.Fatalf("add %s: %v", sp.Ref, err) } } // Deleting a space removes it from the filter and leaves the rest. if _, err := pool.ExecContext(ctx, `DELETE FROM space WHERE id = $1`, notes.ID); err != nil { t.Fatalf("delete space: %v", err) } spaces, err := s.ProjectSpaces(ctx, proj.ID) if err != nil { t.Fatalf("project spaces: %v", err) } if len(spaces) != 1 || spaces[0].ID != rfcs.ID { t.Fatalf("after deleting a member space the project selects %+v", spaces) } // Deleting the project drops the membership rows and no space. if err := s.DeleteProject(ctx, proj.ID); err != nil { t.Fatalf("delete project: %v", err) } var members int if err := pool.QueryRowContext(ctx, `SELECT count(*) FROM project_space WHERE project_id = $1`, proj.ID).Scan(&members); err != nil { t.Fatalf("count membership rows: %v", err) } if members != 0 { t.Errorf("%d membership rows survived the project", members) } if _, err := s.GetSpaceByID(ctx, rfcs.ID); err != nil { t.Fatalf("deleting a project deleted a space: %v", err) } } // Projects add no ID scoping whatsoever: document IDs are global, so the same // ID cannot exist in two spaces regardless of which projects contain them. func TestProjectsDoNotScopeDocumentIDs(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() rfcs := mkSpace(t, s, ctx, "bigbes", "rfcs") notes := mkSpace(t, s, ctx, "bigbes", "notes") a := mkProject(t, s, ctx, "bigbes", "a") b := mkProject(t, s, ctx, "bigbes", "b") if err := s.AddProjectSpace(ctx, a.ID, rfcs.ID); err != nil { t.Fatalf("add rfcs to a: %v", err) } if err := s.AddProjectSpace(ctx, b.ID, notes.ID); err != nil { t.Fatalf("add notes to b: %v", err) } id := docID(t, "SPEC-7") if _, err := s.RegisterDocID(ctx, rfcs.ID, DocRef{ID: id, Path: "specs/0007.md"}, "abc"); err != nil { t.Fatalf("register: %v", err) } // Disjoint projects, and still a collision: uniqueness is global. if _, err := s.RegisterDocID(ctx, notes.ID, DocRef{ID: id, Path: "notes/0007.md"}, "abc"); !errors.Is(err, ErrDocIDTaken) { t.Fatalf("same id in a disjoint project = %v, want ErrDocIDTaken", err) } }