tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
refactor: add shims
desertthunder.dev
4 months ago
a1fb0ec4
14ea0220
+242
-200
3 changed files
expand all
collapse all
unified
split
internal
store
database.go
database_test.go
migration.go
+24
-15
internal/store/database.go
···
11
_ "github.com/mattn/go-sqlite3"
12
)
13
0
0
0
0
0
0
0
0
0
14
//go:embed sql/migrations
15
var migrationFiles embed.FS
16
···
24
var GetConfigDir = func() (string, error) {
25
var configDir string
26
27
-
switch runtime.GOOS {
28
case "windows":
29
appData := os.Getenv("APPDATA")
30
if appData == "" {
···
32
}
33
configDir = filepath.Join(appData, "noteleaf")
34
case "darwin":
35
-
homeDir, err := os.UserHomeDir()
36
if err != nil {
37
return "", fmt.Errorf("failed to get user home directory: %w", err)
38
}
···
40
default:
41
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
42
if xdgConfigHome == "" {
43
-
homeDir, err := os.UserHomeDir()
44
if err != nil {
45
return "", fmt.Errorf("failed to get user home directory: %w", err)
46
}
···
49
configDir = filepath.Join(xdgConfigHome, "noteleaf")
50
}
51
52
-
if err := os.MkdirAll(configDir, 0755); err != nil {
53
return "", fmt.Errorf("failed to create config directory: %w", err)
54
}
55
···
59
// GetDataDir returns the appropriate data directory based on [runtime.GOOS] or NOTELEAF_DATA_DIR
60
var GetDataDir = func() (string, error) {
61
if envDataDir := os.Getenv("NOTELEAF_DATA_DIR"); envDataDir != "" {
62
-
if err := os.MkdirAll(envDataDir, 0755); err != nil {
63
return "", fmt.Errorf("failed to create data directory: %w", err)
64
}
65
return envDataDir, nil
···
67
68
var dataDir string
69
70
-
switch runtime.GOOS {
71
case "windows":
72
localAppData := os.Getenv("LOCALAPPDATA")
73
if localAppData == "" {
···
75
}
76
dataDir = filepath.Join(localAppData, "noteleaf")
77
case "darwin":
78
-
homeDir, err := os.UserHomeDir()
79
if err != nil {
80
return "", fmt.Errorf("failed to get user home directory: %w", err)
81
}
···
83
default:
84
xdgDataHome := os.Getenv("XDG_DATA_HOME")
85
if xdgDataHome == "" {
86
-
homeDir, err := os.UserHomeDir()
87
if err != nil {
88
return "", fmt.Errorf("failed to get user home directory: %w", err)
89
}
···
92
dataDir = filepath.Join(xdgDataHome, "noteleaf")
93
}
94
95
-
if err := os.MkdirAll(dataDir, 0755); err != nil {
96
return "", fmt.Errorf("failed to create data directory: %w", err)
97
}
98
···
118
if config.DatabasePath != "" {
119
dbPath = config.DatabasePath
120
dbDir := filepath.Dir(dbPath)
121
-
if err := os.MkdirAll(dbDir, 0755); err != nil {
122
return nil, fmt.Errorf("failed to create database directory: %w", err)
123
}
124
} else if config.DataDir != "" {
···
131
dbPath = filepath.Join(dataDir, "noteleaf.db")
132
}
133
134
-
db, err := sql.Open("sqlite3", dbPath)
135
if err != nil {
136
return nil, fmt.Errorf("failed to open database: %w", err)
137
}
138
139
-
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
140
db.Close()
141
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
142
}
143
144
-
if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil {
145
db.Close()
146
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
147
}
148
149
database := &Database{DB: db, path: dbPath}
150
-
runner := NewMigrationRunner(db, migrationFiles)
151
if err := runner.RunMigrations(); err != nil {
152
db.Close()
153
return nil, fmt.Errorf("failed to run migrations: %w", err)
···
158
159
// NewMigrationRunnerFromDB creates a new migration runner from a Database instance
160
func (db *Database) NewMigrationRunner() *MigrationRunner {
161
-
return NewMigrationRunner(db.DB, migrationFiles)
162
}
163
164
// GetPath returns the database file path
···
11
_ "github.com/mattn/go-sqlite3"
12
)
13
14
+
var (
15
+
sqlOpen = sql.Open
16
+
pragmaExec = func(db *sql.DB, stmt string) (sql.Result, error) { return db.Exec(stmt) }
17
+
newMigrationRunner = NewMigrationRunner
18
+
getRuntime = func() string { return runtime.GOOS }
19
+
getHomeDir = os.UserHomeDir
20
+
mkdirAll = os.MkdirAll
21
+
)
22
+
23
//go:embed sql/migrations
24
var migrationFiles embed.FS
25
···
33
var GetConfigDir = func() (string, error) {
34
var configDir string
35
36
+
switch getRuntime() {
37
case "windows":
38
appData := os.Getenv("APPDATA")
39
if appData == "" {
···
41
}
42
configDir = filepath.Join(appData, "noteleaf")
43
case "darwin":
44
+
homeDir, err := getHomeDir()
45
if err != nil {
46
return "", fmt.Errorf("failed to get user home directory: %w", err)
47
}
···
49
default:
50
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
51
if xdgConfigHome == "" {
52
+
homeDir, err := getHomeDir()
53
if err != nil {
54
return "", fmt.Errorf("failed to get user home directory: %w", err)
55
}
···
58
configDir = filepath.Join(xdgConfigHome, "noteleaf")
59
}
60
61
+
if err := mkdirAll(configDir, 0755); err != nil {
62
return "", fmt.Errorf("failed to create config directory: %w", err)
63
}
64
···
68
// GetDataDir returns the appropriate data directory based on [runtime.GOOS] or NOTELEAF_DATA_DIR
69
var GetDataDir = func() (string, error) {
70
if envDataDir := os.Getenv("NOTELEAF_DATA_DIR"); envDataDir != "" {
71
+
if err := mkdirAll(envDataDir, 0755); err != nil {
72
return "", fmt.Errorf("failed to create data directory: %w", err)
73
}
74
return envDataDir, nil
···
76
77
var dataDir string
78
79
+
switch getRuntime() {
80
case "windows":
81
localAppData := os.Getenv("LOCALAPPDATA")
82
if localAppData == "" {
···
84
}
85
dataDir = filepath.Join(localAppData, "noteleaf")
86
case "darwin":
87
+
homeDir, err := getHomeDir()
88
if err != nil {
89
return "", fmt.Errorf("failed to get user home directory: %w", err)
90
}
···
92
default:
93
xdgDataHome := os.Getenv("XDG_DATA_HOME")
94
if xdgDataHome == "" {
95
+
homeDir, err := getHomeDir()
96
if err != nil {
97
return "", fmt.Errorf("failed to get user home directory: %w", err)
98
}
···
101
dataDir = filepath.Join(xdgDataHome, "noteleaf")
102
}
103
104
+
if err := mkdirAll(dataDir, 0755); err != nil {
105
return "", fmt.Errorf("failed to create data directory: %w", err)
106
}
107
···
127
if config.DatabasePath != "" {
128
dbPath = config.DatabasePath
129
dbDir := filepath.Dir(dbPath)
130
+
if err := mkdirAll(dbDir, 0755); err != nil {
131
return nil, fmt.Errorf("failed to create database directory: %w", err)
132
}
133
} else if config.DataDir != "" {
···
140
dbPath = filepath.Join(dataDir, "noteleaf.db")
141
}
142
143
+
db, err := sqlOpen("sqlite3", dbPath)
144
if err != nil {
145
return nil, fmt.Errorf("failed to open database: %w", err)
146
}
147
148
+
if _, err := pragmaExec(db, "PRAGMA foreign_keys = ON"); err != nil {
149
db.Close()
150
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
151
}
152
153
+
if _, err := pragmaExec(db, "PRAGMA journal_mode = WAL"); err != nil {
154
db.Close()
155
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
156
}
157
158
database := &Database{DB: db, path: dbPath}
159
+
runner := newMigrationRunner(db, migrationFiles)
160
if err := runner.RunMigrations(); err != nil {
161
db.Close()
162
return nil, fmt.Errorf("failed to run migrations: %w", err)
···
167
168
// NewMigrationRunnerFromDB creates a new migration runner from a Database instance
169
func (db *Database) NewMigrationRunner() *MigrationRunner {
170
+
return newMigrationRunner(db.DB, migrationFiles)
171
}
172
173
// GetPath returns the database file path
+205
-182
internal/store/database_test.go
···
1
package store
2
3
import (
0
0
4
"os"
5
"path/filepath"
6
"runtime"
0
7
"testing"
8
)
9
10
-
func TestNewDatabase(t *testing.T) {
0
11
tempDir, err := os.MkdirTemp("", "noteleaf-db-test-*")
12
if err != nil {
13
t.Fatalf("Failed to create temp directory: %v", err)
14
}
15
-
defer os.RemoveAll(tempDir)
0
0
0
0
0
0
0
16
17
-
originalGetConfigDir := GetConfigDir
18
-
originalGetDataDir := GetDataDir
19
-
GetConfigDir = func() (string, error) {
20
-
return tempDir, nil
21
-
}
22
-
GetDataDir = func() (string, error) {
23
-
return tempDir, nil
24
-
}
25
-
defer func() {
26
-
GetConfigDir = originalGetConfigDir
27
-
GetDataDir = originalGetDataDir
28
-
}()
29
30
-
t.Run("creates database successfully", func(t *testing.T) {
31
db, err := NewDatabase()
32
if err != nil {
33
t.Fatalf("NewDatabase failed: %v", err)
34
}
35
defer db.Close()
36
37
-
if db == nil {
38
-
t.Fatal("Database should not be nil")
39
-
}
40
-
41
dbPath := filepath.Join(tempDir, "noteleaf.db")
42
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
43
-
t.Error("Database file should exist")
44
-
}
45
-
46
-
if db.GetPath() != dbPath {
47
-
t.Errorf("Expected database path %s, got %s", dbPath, db.GetPath())
48
}
49
})
50
51
-
t.Run("enables foreign keys", func(t *testing.T) {
52
-
db, err := NewDatabase()
53
-
if err != nil {
54
-
t.Fatalf("NewDatabase failed: %v", err)
55
-
}
56
defer db.Close()
57
58
-
var foreignKeys int
59
-
err = db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys)
60
-
if err != nil {
61
-
t.Fatalf("Failed to check foreign keys: %v", err)
62
}
63
-
64
-
if foreignKeys != 1 {
65
-
t.Error("Foreign keys should be enabled")
66
}
67
})
68
69
-
t.Run("enables WAL mode", func(t *testing.T) {
70
-
db, err := NewDatabase()
71
-
if err != nil {
72
-
t.Fatalf("NewDatabase failed: %v", err)
73
-
}
74
defer db.Close()
75
76
-
var journalMode string
77
-
err = db.QueryRow("PRAGMA journal_mode").Scan(&journalMode)
78
-
if err != nil {
79
-
t.Fatalf("Failed to check journal mode: %v", err)
80
}
81
-
82
-
if journalMode != "wal" {
83
-
t.Errorf("Expected WAL journal mode, got %s", journalMode)
84
}
85
})
86
-
87
-
t.Run("runs migrations", func(t *testing.T) {
88
-
db, err := NewDatabase()
89
-
if err != nil {
90
-
t.Fatalf("NewDatabase failed: %v", err)
91
-
}
92
-
defer db.Close()
93
94
-
var count int
95
-
err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&count)
96
-
if err != nil {
97
-
t.Fatalf("Failed to check migrations table: %v", err)
0
98
}
0
99
100
-
if count != 1 {
101
-
t.Error("Migrations table should exist")
0
102
}
0
103
104
-
var migrationCount int
105
-
err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&migrationCount)
106
-
if err != nil {
107
-
t.Fatalf("Failed to count migrations: %v", err)
0
0
0
108
}
0
109
110
-
if migrationCount == 0 {
111
-
t.Error("At least one migration should be applied")
0
112
}
113
})
114
115
-
t.Run("creates migration runner", func(t *testing.T) {
116
-
db, err := NewDatabase()
117
-
if err != nil {
118
-
t.Fatalf("NewDatabase failed: %v", err)
0
0
0
119
}
120
-
defer db.Close()
121
122
-
runner := db.NewMigrationRunner()
123
-
if runner == nil {
124
-
t.Error("Migration runner should not be nil")
125
}
126
})
127
128
-
t.Run("closes database connection", func(t *testing.T) {
129
-
db, err := NewDatabase()
130
-
if err != nil {
131
-
t.Fatalf("NewDatabase failed: %v", err)
132
}
133
-
134
-
err = db.Close()
135
-
if err != nil {
136
-
t.Errorf("Close should not return error: %v", err)
137
-
}
138
139
-
err = db.Ping()
140
-
if err == nil {
141
-
t.Error("Database should be closed and ping should fail")
142
}
143
})
144
-
}
145
146
-
func TestDatabaseErrorHandling(t *testing.T) {
147
-
t.Run("handles GetConfigDir error", func(t *testing.T) {
148
-
originalGetConfigDir := GetConfigDir
149
-
GetConfigDir = func() (string, error) {
150
-
return "", os.ErrPermission
151
}
152
-
defer func() { GetConfigDir = originalGetConfigDir }()
0
0
0
0
153
154
_, err := NewDatabase()
155
if err == nil {
156
-
t.Error("NewDatabase should fail when GetConfigDir fails")
157
}
158
})
0
159
160
-
t.Run("handles invalid database path", func(t *testing.T) {
161
-
originalGetConfigDir := GetConfigDir
162
-
GetConfigDir = func() (string, error) {
163
-
return "/invalid/path/that/does/not/exist", nil
0
0
0
0
0
0
0
0
0
0
164
}
165
-
defer func() { GetConfigDir = originalGetConfigDir }()
166
-
167
-
_, err := NewDatabase()
168
-
if err == nil {
169
-
t.Error("NewDatabase should fail with invalid database path")
170
}
171
})
172
173
-
t.Run("handles invalid database connection", func(t *testing.T) {
174
-
originalGetConfigDir := GetConfigDir
175
-
GetConfigDir = func() (string, error) {
176
-
return "/dev/null", nil
177
-
}
178
-
defer func() { GetConfigDir = originalGetConfigDir }()
179
180
-
_, err := NewDatabase()
181
-
if err == nil {
182
-
t.Error("NewDatabase should fail when database path is invalid")
0
0
183
}
184
})
185
186
-
t.Run("handles database file permission error", func(t *testing.T) {
187
-
if runtime.GOOS == "windows" {
188
-
t.Skip("Permission test not reliable on Windows")
189
-
}
0
0
0
0
190
191
-
tempDir, err := os.MkdirTemp("", "noteleaf-db-perm-test-*")
192
if err != nil {
193
-
t.Fatalf("Failed to create temp directory: %v", err)
0
0
0
0
194
}
195
-
defer os.RemoveAll(tempDir)
196
197
-
originalGetConfigDir := GetConfigDir
198
-
GetConfigDir = func() (string, error) {
199
-
return tempDir, nil
200
-
}
201
-
defer func() { GetConfigDir = originalGetConfigDir }()
0
0
0
202
203
-
err = os.Chmod(tempDir, 0555)
0
0
204
if err != nil {
205
-
t.Fatalf("Failed to change directory permissions: %v", err)
206
}
207
-
defer os.Chmod(tempDir, 0755)
208
-
209
-
_, err = NewDatabase()
210
-
if err == nil {
211
-
t.Error("NewDatabase should fail when database directory is not writable")
212
}
213
})
214
}
215
216
-
func TestDatabaseIntegration(t *testing.T) {
217
-
tempDir, err := os.MkdirTemp("", "noteleaf-db-integration-test-*")
218
-
if err != nil {
219
-
t.Fatalf("Failed to create temp directory: %v", err)
220
-
}
221
-
defer os.RemoveAll(tempDir)
222
223
-
originalGetConfigDir := GetConfigDir
224
-
originalGetDataDir := GetDataDir
225
-
GetConfigDir = func() (string, error) {
226
-
return tempDir, nil
227
-
}
228
-
GetDataDir = func() (string, error) {
229
-
return tempDir, nil
230
-
}
231
-
defer func() {
232
-
GetConfigDir = originalGetConfigDir
233
-
GetDataDir = originalGetDataDir
234
-
}()
235
236
-
t.Run("multiple database instances use same file", func(t *testing.T) {
237
-
db1, err := NewDatabase()
238
if err != nil {
239
-
t.Fatalf("First NewDatabase failed: %v", err)
240
}
241
-
defer db1.Close()
0
0
0
242
243
-
db2, err := NewDatabase()
0
0
0
0
0
0
0
0
244
if err != nil {
245
-
t.Fatalf("Second NewDatabase failed: %v", err)
246
}
247
-
defer db2.Close()
248
-
249
-
if db1.GetPath() != db2.GetPath() {
250
-
t.Error("Both database instances should use the same file path")
251
}
252
})
253
254
-
t.Run("database survives connection close and reopen", func(t *testing.T) {
255
-
db1, err := NewDatabase()
256
-
if err != nil {
257
-
t.Fatalf("NewDatabase failed: %v", err)
258
-
}
259
260
-
_, err = db1.Exec("CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, name TEXT)")
261
-
if err != nil {
262
-
t.Fatalf("Failed to create test table: %v", err)
263
-
}
264
265
-
_, err = db1.Exec("INSERT INTO test_table (name) VALUES (?)", "test_value")
266
-
if err != nil {
267
-
t.Fatalf("Failed to insert test data: %v", err)
268
}
0
269
270
-
db1.Close()
0
0
0
0
0
0
0
271
272
-
db2, err := NewDatabase()
273
if err != nil {
274
-
t.Fatalf("Second NewDatabase failed: %v", err)
275
}
276
-
defer db2.Close()
0
0
0
0
277
278
-
var name string
279
-
err = db2.QueryRow("SELECT name FROM test_table WHERE id = 1").Scan(&name)
0
0
0
0
0
0
0
0
0
0
280
if err != nil {
281
-
t.Fatalf("Failed to query test data: %v", err)
282
}
283
-
284
-
if name != "test_value" {
285
-
t.Errorf("Expected 'test_value', got '%s'", name)
286
}
287
})
288
}
···
1
package store
2
3
import (
4
+
"database/sql"
5
+
"fmt"
6
"os"
7
"path/filepath"
8
"runtime"
9
+
"strings"
10
"testing"
11
)
12
13
+
func withTempDirs(t *testing.T) string {
14
+
t.Helper()
15
tempDir, err := os.MkdirTemp("", "noteleaf-db-test-*")
16
if err != nil {
17
t.Fatalf("Failed to create temp directory: %v", err)
18
}
19
+
t.Cleanup(func() { os.RemoveAll(tempDir) })
20
+
21
+
origConfig, origData := GetConfigDir, GetDataDir
22
+
GetConfigDir = func() (string, error) { return tempDir, nil }
23
+
GetDataDir = func() (string, error) { return tempDir, nil }
24
+
t.Cleanup(func() {
25
+
GetConfigDir, GetDataDir = origConfig, origData
26
+
})
27
28
+
return tempDir
29
+
}
30
+
31
+
func TestNewDatabase(t *testing.T) {
32
+
tempDir := withTempDirs(t)
0
0
0
0
0
0
0
33
34
+
t.Run("creates database file", func(t *testing.T) {
35
db, err := NewDatabase()
36
if err != nil {
37
t.Fatalf("NewDatabase failed: %v", err)
38
}
39
defer db.Close()
40
0
0
0
0
41
dbPath := filepath.Join(tempDir, "noteleaf.db")
42
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
43
+
t.Errorf("expected database file at %s", dbPath)
0
0
0
0
44
}
45
})
46
47
+
t.Run("foreign keys enabled", func(t *testing.T) {
48
+
db, _ := NewDatabase()
0
0
0
49
defer db.Close()
50
51
+
var fk int
52
+
if err := db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
53
+
t.Fatalf("query failed: %v", err)
0
54
}
55
+
if fk != 1 {
56
+
t.Errorf("expected foreign_keys=1, got %d", fk)
0
57
}
58
})
59
60
+
t.Run("WAL enabled", func(t *testing.T) {
61
+
db, _ := NewDatabase()
0
0
0
62
defer db.Close()
63
64
+
var mode string
65
+
if err := db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
66
+
t.Fatalf("query failed: %v", err)
0
67
}
68
+
if strings.ToLower(mode) != "wal" {
69
+
t.Errorf("expected wal, got %s", mode)
0
70
}
71
})
72
+
}
0
0
0
0
0
0
73
74
+
func TestNewDatabase_ErrorPaths(t *testing.T) {
75
+
t.Run("sql.Open fails", func(t *testing.T) {
76
+
orig := sqlOpen
77
+
sqlOpen = func(driver, dsn string) (*sql.DB, error) {
78
+
return nil, fmt.Errorf("boom")
79
}
80
+
t.Cleanup(func() { sqlOpen = orig })
81
82
+
_, err := NewDatabase()
83
+
if err == nil || !strings.Contains(err.Error(), "failed to open database") {
84
+
t.Errorf("expected open error, got %v", err)
85
}
86
+
})
87
88
+
t.Run("foreign_keys pragma fails", func(t *testing.T) {
89
+
orig := pragmaExec
90
+
pragmaExec = func(db *sql.DB, stmt string) (sql.Result, error) {
91
+
if strings.Contains(stmt, "foreign_keys") {
92
+
return nil, fmt.Errorf("fk fail")
93
+
}
94
+
return orig(db, stmt)
95
}
96
+
t.Cleanup(func() { pragmaExec = orig })
97
98
+
_, err := NewDatabase()
99
+
if err == nil || !strings.Contains(err.Error(), "failed to enable foreign keys") {
100
+
t.Errorf("expected foreign key error, got %v", err)
101
}
102
})
103
104
+
t.Run("WAL pragma fails", func(t *testing.T) {
105
+
orig := pragmaExec
106
+
pragmaExec = func(db *sql.DB, stmt string) (sql.Result, error) {
107
+
if strings.Contains(stmt, "journal_mode") {
108
+
return nil, fmt.Errorf("wal fail")
109
+
}
110
+
return orig(db, stmt)
111
}
112
+
t.Cleanup(func() { pragmaExec = orig })
113
114
+
_, err := NewDatabase()
115
+
if err == nil || !strings.Contains(err.Error(), "failed to enable WAL mode") {
116
+
t.Errorf("expected WAL error, got %v", err)
117
}
118
})
119
120
+
t.Run("migration runner fails", func(t *testing.T) {
121
+
orig := newMigrationRunner
122
+
newMigrationRunner = func(db *sql.DB, fs FileSystem) *MigrationRunner {
123
+
return &MigrationRunner{runFn: func() error { return fmt.Errorf("migration fail") }}
124
}
125
+
t.Cleanup(func() { newMigrationRunner = orig })
0
0
0
0
126
127
+
_, err := NewDatabase()
128
+
if err == nil || !strings.Contains(err.Error(), "failed to run migrations") {
129
+
t.Errorf("expected migration error, got %v", err)
130
}
131
})
0
132
133
+
t.Run("permission denied on config dir", func(t *testing.T) {
134
+
if runtime.GOOS == "windows" {
135
+
t.Skip("not reliable on Windows")
0
0
136
}
137
+
dir := withTempDirs(t)
138
+
os.Chmod(dir, 0555) // make read-only
139
+
defer os.Chmod(dir, 0755)
140
+
141
+
GetConfigDir = func() (string, error) { return dir, nil }
142
143
_, err := NewDatabase()
144
if err == nil {
145
+
t.Error("expected mkdir fail due to permission denied")
146
}
147
})
148
+
}
149
150
+
func TestGetConfigDir_AllBranches(t *testing.T) {
151
+
tmp := t.TempDir()
152
+
153
+
t.Run("windows success", func(t *testing.T) {
154
+
origGOOS := getRuntime
155
+
getRuntime = func() string { return "windows" }
156
+
defer func() { getRuntime = origGOOS }()
157
+
158
+
os.Setenv("APPDATA", tmp)
159
+
defer os.Unsetenv("APPDATA")
160
+
161
+
dir, err := GetConfigDir()
162
+
if err != nil {
163
+
t.Fatalf("unexpected error: %v", err)
164
}
165
+
expected := filepath.Join(tmp, "noteleaf")
166
+
if dir != expected {
167
+
t.Errorf("expected %s, got %s", expected, dir)
0
0
168
}
169
})
170
171
+
t.Run("windows missing APPDATA", func(t *testing.T) {
172
+
origGOOS := getRuntime
173
+
getRuntime = func() string { return "windows" }
174
+
defer func() { getRuntime = origGOOS }()
0
0
175
176
+
os.Unsetenv("APPDATA")
177
+
178
+
_, err := GetConfigDir()
179
+
if err == nil || !strings.Contains(err.Error(), "APPDATA") {
180
+
t.Errorf("expected APPDATA error, got %v", err)
181
}
182
})
183
184
+
t.Run("darwin success", func(t *testing.T) {
185
+
origGOOS, origHome := getRuntime, getHomeDir
186
+
getRuntime = func() string { return "darwin" }
187
+
getHomeDir = func() (string, error) { return tmp, nil }
188
+
defer func() {
189
+
getRuntime = origGOOS
190
+
getHomeDir = origHome
191
+
}()
192
193
+
dir, err := GetConfigDir()
194
if err != nil {
195
+
t.Fatalf("unexpected error: %v", err)
196
+
}
197
+
expected := filepath.Join(tmp, "Library", "Application Support", "noteleaf")
198
+
if dir != expected {
199
+
t.Errorf("expected %s, got %s", expected, dir)
200
}
201
+
})
202
203
+
t.Run("linux default with XDG_CONFIG_HOME unset", func(t *testing.T) {
204
+
origGOOS, origHome := getRuntime, getHomeDir
205
+
getRuntime = func() string { return "linux" }
206
+
getHomeDir = func() (string, error) { return tmp, nil }
207
+
defer func() {
208
+
getRuntime = origGOOS
209
+
getHomeDir = origHome
210
+
}()
211
212
+
os.Unsetenv("XDG_CONFIG_HOME")
213
+
214
+
dir, err := GetConfigDir()
215
if err != nil {
216
+
t.Fatalf("unexpected error: %v", err)
217
}
218
+
expected := filepath.Join(tmp, ".config", "noteleaf")
219
+
if dir != expected {
220
+
t.Errorf("expected %s, got %s", expected, dir)
0
0
221
}
222
})
223
}
224
225
+
func TestGetDataDir_AllBranches(t *testing.T) {
226
+
tmp := t.TempDir()
0
0
0
0
227
228
+
t.Run("NOTELEAF_DATA_DIR overrides", func(t *testing.T) {
229
+
os.Setenv("NOTELEAF_DATA_DIR", tmp)
230
+
defer os.Unsetenv("NOTELEAF_DATA_DIR")
0
0
0
0
0
0
0
0
0
231
232
+
dir, err := GetDataDir()
0
233
if err != nil {
234
+
t.Fatalf("unexpected error: %v", err)
235
}
236
+
if dir != tmp {
237
+
t.Errorf("expected %s, got %s", tmp, dir)
238
+
}
239
+
})
240
241
+
t.Run("windows success", func(t *testing.T) {
242
+
origGOOS := getRuntime
243
+
getRuntime = func() string { return "windows" }
244
+
defer func() { getRuntime = origGOOS }()
245
+
246
+
os.Setenv("LOCALAPPDATA", tmp)
247
+
defer os.Unsetenv("LOCALAPPDATA")
248
+
249
+
dir, err := GetDataDir()
250
if err != nil {
251
+
t.Fatalf("unexpected error: %v", err)
252
}
253
+
expected := filepath.Join(tmp, "noteleaf")
254
+
if dir != expected {
255
+
t.Errorf("expected %s, got %s", expected, dir)
0
256
}
257
})
258
259
+
t.Run("windows missing LOCALAPPDATA", func(t *testing.T) {
260
+
origGOOS := getRuntime
261
+
getRuntime = func() string { return "windows" }
262
+
defer func() { getRuntime = origGOOS }()
0
263
264
+
os.Unsetenv("LOCALAPPDATA")
0
0
0
265
266
+
_, err := GetDataDir()
267
+
if err == nil || !strings.Contains(err.Error(), "LOCALAPPDATA") {
268
+
t.Errorf("expected LOCALAPPDATA error, got %v", err)
269
}
270
+
})
271
272
+
t.Run("darwin success", func(t *testing.T) {
273
+
origGOOS, origHome := getRuntime, getHomeDir
274
+
getRuntime = func() string { return "darwin" }
275
+
getHomeDir = func() (string, error) { return tmp, nil }
276
+
defer func() {
277
+
getRuntime = origGOOS
278
+
getHomeDir = origHome
279
+
}()
280
281
+
dir, err := GetDataDir()
282
if err != nil {
283
+
t.Fatalf("unexpected error: %v", err)
284
}
285
+
expected := filepath.Join(tmp, "Library", "Application Support", "noteleaf")
286
+
if dir != expected {
287
+
t.Errorf("expected %s, got %s", expected, dir)
288
+
}
289
+
})
290
291
+
t.Run("linux default with XDG_DATA_HOME unset", func(t *testing.T) {
292
+
origGOOS, origHome := getRuntime, getHomeDir
293
+
getRuntime = func() string { return "linux" }
294
+
getHomeDir = func() (string, error) { return tmp, nil }
295
+
defer func() {
296
+
getRuntime = origGOOS
297
+
getHomeDir = origHome
298
+
}()
299
+
300
+
os.Unsetenv("XDG_DATA_HOME")
301
+
302
+
dir, err := GetDataDir()
303
if err != nil {
304
+
t.Fatalf("unexpected error: %v", err)
305
}
306
+
expected := filepath.Join(tmp, ".local", "share", "noteleaf")
307
+
if dir != expected {
308
+
t.Errorf("expected %s, got %s", expected, dir)
309
}
310
})
311
}
+13
-3
internal/store/migration.go
···
28
type MigrationRunner struct {
29
db *sql.DB
30
migrationFiles FileSystem
0
31
}
32
33
// NewMigrationRunner creates a new migration runner
34
func NewMigrationRunner(db *sql.DB, files FileSystem) *MigrationRunner {
35
-
return &MigrationRunner{
36
db: db,
37
migrationFiles: files,
38
}
0
0
39
}
40
41
-
// RunMigrations applies all pending migrations
42
func (mr *MigrationRunner) RunMigrations() error {
0
0
0
0
0
0
0
43
entries, err := mr.migrationFiles.ReadDir("sql/migrations")
44
if err != nil {
45
return fmt.Errorf("failed to read migrations directory: %w", err)
···
214
return nil
215
}
216
217
-
// extractVersionFromFilename extracts the 4-digit version from a migration filename
218
func extractVersionFromFilename(filename string) string {
219
parts := strings.Split(filename, "_")
220
if len(parts) > 0 {
···
28
type MigrationRunner struct {
29
db *sql.DB
30
migrationFiles FileSystem
31
+
runFn func() error // inject for testing
32
}
33
34
// NewMigrationRunner creates a new migration runner
35
func NewMigrationRunner(db *sql.DB, files FileSystem) *MigrationRunner {
36
+
mr := &MigrationRunner{
37
db: db,
38
migrationFiles: files,
39
}
40
+
mr.runFn = mr.defaultRunMigrations
41
+
return mr
42
}
43
44
+
// RunMigrations applies all pending migrations (delegates to runFn)
45
func (mr *MigrationRunner) RunMigrations() error {
46
+
if mr.runFn != nil {
47
+
return mr.runFn()
48
+
}
49
+
return nil
50
+
}
51
+
52
+
func (mr *MigrationRunner) defaultRunMigrations() error {
53
entries, err := mr.migrationFiles.ReadDir("sql/migrations")
54
if err != nil {
55
return fmt.Errorf("failed to read migrations directory: %w", err)
···
224
return nil
225
}
226
227
+
// extractVersionFromFilename extracts the 4-digit version from a [Migration] filename
228
func extractVersionFromFilename(filename string) string {
229
parts := strings.Split(filename, "_")
230
if len(parts) > 0 {