check / check (push) Successful in 2m22s
scan opened any database whose user_version was 0 as empty and ran an unconditional CREATE TABLE files, so a foreign or partially initialized file with a files table failed with the raw SQLite "table files already exists", which explains nothing. createSchema now checks for an existing files table first and, when one is present at version 0, reports the schema-version error telling the operator to remove the file and rescan, matching the README rule that an unrecognized database is fatal. Model: opus-4-8
260 lines
5.7 KiB
Go
260 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// testDBPath returns a database path inside a fresh temp dir.
|
|
func testDBPath(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return filepath.Join(t.TempDir(), "db.sqlite")
|
|
}
|
|
|
|
// openTestDB creates a fresh scan database in a temp dir.
|
|
func openTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
db, err := openScanDatabase(t.Context(), testDBPath(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db
|
|
}
|
|
|
|
func TestDatabasePath(t *testing.T) {
|
|
t.Setenv(databaseEnv, "")
|
|
|
|
if got := databasePath(); got != defaultDatabasePath {
|
|
t.Errorf("databasePath() = %q, want %q", got, defaultDatabasePath)
|
|
}
|
|
|
|
t.Setenv(databaseEnv, "/custom/place.sqlite")
|
|
|
|
if got := databasePath(); got != "/custom/place.sqlite" {
|
|
t.Errorf("databasePath() = %q, want the env override", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenScanDatabaseCreates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The parent directory does not exist yet; scan must create it.
|
|
path := filepath.Join(t.TempDir(), "nested", "dir", "db.sqlite")
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openScanDatabase: %v", err)
|
|
}
|
|
|
|
v, err := userVersion(t.Context(), db)
|
|
if err != nil || v != schemaVersion {
|
|
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
// Reopening an existing database must succeed and find the schema.
|
|
db, err = openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
recs, err := loadFileRows(t.Context(), db)
|
|
if err != nil || len(recs) != 0 {
|
|
t.Fatalf("loadFileRows = %v, %v; want empty, nil", recs, err)
|
|
}
|
|
}
|
|
|
|
func TestOpenScanDatabaseUnversionedForeign(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := testDBPath(t)
|
|
|
|
// A database that has a files table but user_version 0 — a foreign
|
|
// or partially initialized file. scan must refuse it with a clear
|
|
// schema-version error, not adopt it and not emit a raw SQLite
|
|
// "table files already exists".
|
|
db, err := openDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(t.Context(), "CREATE TABLE files (x INTEGER)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
_, err = openScanDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) {
|
|
t.Fatalf("err = %v, want errSchemaVersion", err)
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "remove the file and rescan") {
|
|
t.Fatalf("err = %v, want it to tell the operator to remove and rescan",
|
|
err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := openReportDatabase(t.Context(), testDBPath(t))
|
|
if !errors.Is(err, errNoDatabase) {
|
|
t.Fatalf("err = %v, want errNoDatabase", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseVersionMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := testDBPath(t)
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(context.Background(), "PRAGMA user_version = 99")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
_, err = openReportDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) {
|
|
t.Fatalf("err = %v, want errSchemaVersion", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseOK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := testDBPath(t)
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
db, err = openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openReportDatabase: %v", err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
}
|
|
|
|
func TestApplyChangesRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
// Paths may contain tabs and newlines; the database must store
|
|
// them byte-exactly.
|
|
recs := []scanRec{
|
|
{size: 2, mtime: 20, head: "h2", tail: "t2", path: "/a/tab\tnew\nline"},
|
|
{size: 1, mtime: 10, head: "h1", tail: "t1", path: "/a/x"},
|
|
}
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil,
|
|
newProgress("update", 2))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
got, err := loadFileRows(t.Context(), db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
slices.SortFunc(got, func(a, b scanRec) int {
|
|
return strings.Compare(a.path, b.path)
|
|
})
|
|
|
|
if !slices.Equal(got, recs) {
|
|
t.Fatalf("rows = %+v, want %+v", got, recs)
|
|
}
|
|
|
|
// An upsert for an existing path updates in place; a delete
|
|
// removes exactly its path.
|
|
upd := scanRec{size: 3, mtime: 30, head: "h3", tail: "t3", path: "/a/x"}
|
|
|
|
err = applyChanges(t.Context(), db, []scanRec{upd},
|
|
[]string{"/a/tab\tnew\nline"}, newProgress("update", 2))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
got, err = loadFileRows(t.Context(), db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(got) != 1 || got[0] != upd {
|
|
t.Fatalf("rows = %+v, want just %+v", got, upd)
|
|
}
|
|
}
|
|
|
|
func TestApplyChangesBatching(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
// One more change than the batch size, so the update spans two
|
|
// transactions.
|
|
n := updateBatchSize + 1
|
|
|
|
recs := make([]scanRec, 0, n)
|
|
for i := range n {
|
|
recs = append(recs, scanRec{
|
|
size: int64(i), mtime: 1, head: "h", tail: "t",
|
|
path: fmt.Sprintf("/batch/%07d", i),
|
|
})
|
|
}
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil,
|
|
newProgress("update", int64(n)))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
got, err := loadFileRows(t.Context(), db)
|
|
if err != nil || len(got) != n {
|
|
t.Fatalf("loadFileRows = %d rows, %v; want %d", len(got), err, n)
|
|
}
|
|
|
|
deletes := make([]string, 0, n)
|
|
for _, r := range recs {
|
|
deletes = append(deletes, r.path)
|
|
}
|
|
|
|
err = applyChanges(t.Context(), db, nil, deletes,
|
|
newProgress("update", int64(n)))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges deletes: %v", err)
|
|
}
|
|
|
|
got, err = loadFileRows(t.Context(), db)
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("loadFileRows = %d rows, %v; want 0", len(got), err)
|
|
}
|
|
}
|