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)
}
}