Files
sfdupes/db_test.go
T
sneak b2a4f748b7
check / check (push) Successful in 2m22s
Refuse an unversioned database that already has a files table (closes #11)
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
2026-09-21 07:23:09 +00:00

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