dnote/pkg/server/database/migrate_test.go
2025-10-19 09:34:07 -07:00

324 lines
7.9 KiB
Go

/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package database
import (
"io/fs"
"testing"
"testing/fstest"
)
// unsortedFS wraps fstest.MapFS to return entries in reverse order
type unsortedFS struct {
fstest.MapFS
}
func (u unsortedFS) ReadDir(name string) ([]fs.DirEntry, error) {
entries, err := u.MapFS.ReadDir(name)
if err != nil {
return nil, err
}
// Reverse the entries to ensure they're not in sorted order
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
return entries, nil
}
// errorFS returns an error on ReadDir
type errorFS struct{}
func (e errorFS) Open(name string) (fs.File, error) {
return nil, fs.ErrNotExist
}
func (e errorFS) ReadDir(name string) ([]fs.DirEntry, error) {
return nil, fs.ErrPermission
}
func TestMigrate_createsSchemaTable(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
migrationsFs := fstest.MapFS{}
migrate(db, migrationsFs)
// Verify schema_migrations table exists
var count int64
if err := db.Raw("SELECT COUNT(*) FROM schema_migrations").Scan(&count).Error; err != nil {
t.Fatalf("schema_migrations table not found: %v", err)
}
}
func TestMigrate_idempotency(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Set up table before migration
if err := db.Exec("CREATE TABLE counter (value INTEGER)").Error; err != nil {
t.Fatalf("failed to create table: %v", err)
}
// Create migration that inserts a row
migrationsFs := fstest.MapFS{
"001-insert-data.sql": &fstest.MapFile{
Data: []byte("INSERT INTO counter (value) VALUES (100);"),
},
}
// Run migration first time
if err := migrate(db, migrationsFs); err != nil {
t.Fatalf("first migration failed: %v", err)
}
var count int64
if err := db.Raw("SELECT COUNT(*) FROM counter").Scan(&count).Error; err != nil {
t.Fatalf("failed to count rows: %v", err)
}
if count != 1 {
t.Errorf("expected 1 row, got %d", count)
}
// Run migration second time - it should not run the SQL again
if err := migrate(db, migrationsFs); err != nil {
t.Fatalf("second migration failed: %v", err)
}
if err := db.Raw("SELECT COUNT(*) FROM counter").Scan(&count).Error; err != nil {
t.Fatalf("failed to count rows: %v", err)
}
if count != 1 {
t.Errorf("migration ran twice: expected 1 row, got %d", count)
}
}
func TestMigrate_ordering(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Create table before migrations
if err := db.Exec("CREATE TABLE log (value INTEGER)").Error; err != nil {
t.Fatalf("failed to create table: %v", err)
}
// Create migrations with unsorted filesystem
migrationsFs := unsortedFS{
MapFS: fstest.MapFS{
"010-tenth.sql": &fstest.MapFile{
Data: []byte("INSERT INTO log (value) VALUES (3);"),
},
"001-first.sql": &fstest.MapFile{
Data: []byte("INSERT INTO log (value) VALUES (1);"),
},
"002-second.sql": &fstest.MapFile{
Data: []byte("INSERT INTO log (value) VALUES (2);"),
},
},
}
// Run migrations
if err := migrate(db, migrationsFs); err != nil {
t.Fatalf("migration failed: %v", err)
}
// Verify migrations ran in correct order (1, 2, 3)
var values []int
if err := db.Raw("SELECT value FROM log ORDER BY rowid").Scan(&values).Error; err != nil {
t.Fatalf("failed to query log: %v", err)
}
expected := []int{1, 2, 3}
if len(values) != len(expected) {
t.Fatalf("expected %d rows, got %d", len(expected), len(values))
}
for i, v := range values {
if v != expected[i] {
t.Errorf("row %d: expected value %d, got %d", i, expected[i], v)
}
}
}
func TestMigrate_duplicateVersion(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Create migrations with duplicate version numbers
migrationsFs := fstest.MapFS{
"001-first.sql": &fstest.MapFile{
Data: []byte("SELECT 1;"),
},
"001-second.sql": &fstest.MapFile{
Data: []byte("SELECT 2;"),
},
}
// Should return error for duplicate version
err := migrate(db, migrationsFs)
if err == nil {
t.Fatal("expected error for duplicate version numbers, got nil")
}
}
func TestMigrate_initTableError(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
// Close the database connection to cause exec to fail
sqlDB, _ := db.DB()
sqlDB.Close()
migrationsFs := fstest.MapFS{
"001-init.sql": &fstest.MapFile{
Data: []byte("SELECT 1;"),
},
}
// Should return error for table initialization failure
err := migrate(db, migrationsFs)
if err == nil {
t.Fatal("expected error for table initialization failure, got nil")
}
}
func TestMigrate_readDirError(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Use filesystem that fails on ReadDir
err := migrate(db, errorFS{})
if err == nil {
t.Fatal("expected error for ReadDir failure, got nil")
}
}
func TestMigrate_sqlError(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Create migration with invalid SQL
migrationsFs := fstest.MapFS{
"001-bad-sql.sql": &fstest.MapFile{
Data: []byte("INVALID SQL SYNTAX HERE;"),
},
}
// Should return error for SQL execution failure
err := migrate(db, migrationsFs)
if err == nil {
t.Fatal("expected error for invalid SQL, got nil")
}
}
func TestMigrate_emptyFile(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
tests := []struct{
name string
data string
wantErr bool
}{
{"completely empty", "", true},
{"only whitespace", " \n\t ", true},
{"only comments", "-- just a comment", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
migrationsFs := fstest.MapFS{
"001-empty.sql": &fstest.MapFile{
Data: []byte(tt.data),
},
}
err := migrate(db, migrationsFs)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestMigrate_invalidFilename(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
tests := []struct {
name string
filename string
wantErr bool
}{
{"valid format", "001-init.sql", false},
{"no leading zeros", "1-init.sql", true},
{"two digits", "01-init.sql", true},
{"no dash", "001init.sql", true},
{"no description", "001-.sql", true},
{"no extension", "001-init.", true},
{"wrong extension", "001-init.txt", true},
{"non-numeric version number", "0a1-init.sql", true},
{"underscore separator", "001_init.sql", true},
{"multiple dashes in description", "001-add-feature-v2.sql", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
migrationsFs := fstest.MapFS{
tt.filename: &fstest.MapFile{
Data: []byte("SELECT 1;"),
},
}
err := migrate(db, migrationsFs)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}