package store import ( "context" "database/sql" "errors" "fmt" "testing" "time" ) func TestCreateAndReadProject(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") p.DisplayName = "Demo Site" p.NotFoundFile = "404.html" p.SPAFallback = true if err := db.CreateProject(ctx, p); err != nil { t.Fatalf("CreateProject: %v", err) } if p.ID == 0 { t.Error("CreateProject must fill in the ID") } if p.CreatedAt.IsZero() || !p.UpdatedAt.Equal(p.CreatedAt) { t.Errorf("timestamps not set: created=%v updated=%v", p.CreatedAt, p.UpdatedAt) } got, err := db.ProjectByName(ctx, "demo") if err != nil { t.Fatalf("ProjectByName: %v", err) } if got.ID != p.ID || got.DisplayName != "Demo Site" || got.NotFoundFile != "404.html" || !got.SPAFallback { t.Errorf("round trip lost data: %+v", got) } if got.IndexFile != "index.html" || got.RetentionCount != 10 { t.Errorf("defaults not persisted: %+v", got) } byID, err := db.ProjectByID(ctx, p.ID) if err != nil { t.Fatalf("ProjectByID: %v", err) } if byID.Name != "demo" { t.Errorf("ProjectByID returned %q", byID.Name) } } // An empty not_found_file must come back as "" rather than as a bogus "NULL" // string, because the resolver branches on it being empty. func TestProjectNullNotFoundFile(t *testing.T) { ctx := context.Background() db := testDB(t) if err := db.CreateProject(ctx, DefaultProject("demo")); err != nil { t.Fatal(err) } got, err := db.ProjectByName(ctx, "demo") if err != nil { t.Fatal(err) } if got.NotFoundFile != "" { t.Errorf("NotFoundFile = %q, want empty", got.NotFoundFile) } } func TestCreateProjectDuplicateName(t *testing.T) { ctx := context.Background() db := testDB(t) if err := db.CreateProject(ctx, DefaultProject("demo")); err != nil { t.Fatal(err) } err := db.CreateProject(ctx, DefaultProject("demo")) if !errors.Is(err, ErrExists) { t.Fatalf("second create: got %v, want ErrExists", err) } } func TestProjectNotFound(t *testing.T) { ctx := context.Background() db := testDB(t) if _, err := db.ProjectByName(ctx, "nope"); !errors.Is(err, ErrNotFound) { t.Errorf("ProjectByName: got %v, want ErrNotFound", err) } if _, err := db.ProjectByID(ctx, 404); !errors.Is(err, ErrNotFound) { t.Errorf("ProjectByID: got %v, want ErrNotFound", err) } if err := db.DeleteProject(ctx, 404); !errors.Is(err, ErrNotFound) { t.Errorf("DeleteProject: got %v, want ErrNotFound", err) } if err := db.UpdateProject(ctx, &Project{ID: 404}); !errors.Is(err, ErrNotFound) { t.Errorf("UpdateProject: got %v, want ErrNotFound", err) } } func TestUpdateProject(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } p.SPAFallback = true p.CacheControl = "public, max-age=31536000, immutable" p.RetentionCount = 3 p.NotFoundFile = "404.html" if err := db.UpdateProject(ctx, p); err != nil { t.Fatalf("UpdateProject: %v", err) } got, err := db.ProjectByName(ctx, "demo") if err != nil { t.Fatal(err) } if !got.SPAFallback || got.RetentionCount != 3 || got.NotFoundFile != "404.html" { t.Errorf("update did not stick: %+v", got) } if got.Name != "demo" { t.Errorf("name must be immutable, got %q", got.Name) } if got.CreatedAt.After(got.UpdatedAt) { t.Errorf("updated_at %v predates created_at %v", got.UpdatedAt, got.CreatedAt) } } // Clearing not_found_file must write SQL NULL, not the empty string, so the // column keeps a single representation of "unset". func TestUpdateProjectClearsNotFoundFile(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") p.NotFoundFile = "404.html" if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } p.NotFoundFile = "" if err := db.UpdateProject(ctx, p); err != nil { t.Fatal(err) } var isNull bool if err := db.Reader().QueryRow( `SELECT not_found_file IS NULL FROM projects WHERE id = ?`, p.ID).Scan(&isNull); err != nil { t.Fatal(err) } if !isNull { t.Error("cleared not_found_file should be stored as NULL") } } func TestListProjectsPaging(t *testing.T) { ctx := context.Background() db := testDB(t) for i := 0; i < 7; i++ { if err := db.CreateProject(ctx, DefaultProject(fmt.Sprintf("p%d", i))); err != nil { t.Fatal(err) } } var names []string cursor := "" for pages := 0; ; pages++ { if pages > 10 { t.Fatal("paging did not terminate") } batch, next, err := db.ListProjects(ctx, 3, cursor) if err != nil { t.Fatal(err) } for _, p := range batch { names = append(names, p.Name) } if next == "" { break } cursor = next } want := []string{"p0", "p1", "p2", "p3", "p4", "p5", "p6"} if len(names) != len(want) { t.Fatalf("paged names = %v, want %v", names, want) } for i := range want { if names[i] != want[i] { t.Fatalf("paged names = %v, want %v", names, want) } } n, err := db.CountProjects(ctx) if err != nil { t.Fatal(err) } if n != 7 { t.Errorf("CountProjects = %d, want 7", n) } all, err := db.AllProjects(ctx) if err != nil { t.Fatal(err) } if len(all) != 7 { t.Errorf("AllProjects returned %d rows, want 7", len(all)) } } // Deleting a project must take its keys, deployments and manifest rows with it, // and must drop the blob refcounts so the content becomes collectable. func TestDeleteProjectCascades(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } digest := make([]byte, 32) digest[0] = 0x7f if err := db.Tx(ctx, func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO deployments (id, public_id, project_id, state, created_at) VALUES (1, 'dpl_a', ?, 'ready', 1)`, p.ID); err != nil { return err } if _, err := tx.Exec(`INSERT INTO blobs (digest, size, present, created_at, last_ref_at) VALUES (?, 5, 1, 1, 1)`, digest); err != nil { return err } _, err := tx.Exec(`INSERT INTO deployment_files (deployment_id, path, digest, size) VALUES (1, 'index.html', ?, 5)`, digest) return err }); err != nil { t.Fatal(err) } pid := p.ID if err := db.CreateKey(ctx, &APIKey{ ID: "keyaaaaaaaaaaaaa", SecretHash: make([]byte, 32), Scope: ScopeProject, ProjectID: &pid, }); err != nil { t.Fatal(err) } if err := db.DeleteProject(ctx, p.ID); err != nil { t.Fatalf("DeleteProject: %v", err) } count := func(query string, args ...any) int { t.Helper() var n int if err := db.Reader().QueryRow(query, args...).Scan(&n); err != nil { t.Fatal(err) } return n } if n := count(`SELECT count(*) FROM deployments`); n != 0 { t.Errorf("%d deployments survived", n) } if n := count(`SELECT count(*) FROM deployment_files`); n != 0 { t.Errorf("%d manifest rows survived", n) } if n := count(`SELECT count(*) FROM api_keys`); n != 0 { t.Errorf("%d keys survived", n) } if n := count(`SELECT refcount FROM blobs WHERE digest = ?`, digest); n != 0 { t.Errorf("blob refcount = %d, want 0 (content would never be collected)", n) } // The blob row itself stays: it is now unreferenced, and reclaiming it is // GC's job, not the delete path's. if n := count(`SELECT count(*) FROM blobs`); n != 1 { t.Errorf("blob row count = %d, want 1", n) } } func TestProjectTimestampsAreUTC(t *testing.T) { ctx := context.Background() db := testDB(t) p := DefaultProject("demo") if err := db.CreateProject(ctx, p); err != nil { t.Fatal(err) } got, err := db.ProjectByName(ctx, "demo") if err != nil { t.Fatal(err) } if got.CreatedAt.Location() != time.UTC { t.Errorf("CreatedAt location = %v, want UTC", got.CreatedAt.Location()) } }