Files
2026-08-15 07:13:00 +00:00

286 lines
7.6 KiB
Go

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