test: add comprehensive unit tests — coverage 20.2% → 91.8%

Add 12 test files covering all internal packages:

- internal/util/util_test.go (97.2%)
- internal/model/model_test.go (100.0%)
- internal/config/config_test.go (88.1%)
- internal/template/builtin_test.go (98.8%)
- internal/middleware/middleware_test.go (98.7%)
- internal/database/repo_extra_test.go (85.5%)
- internal/rules/converter_test.go (99.1%)
- internal/service/subscription_test.go (75.7%)
- internal/handler/handler_test.go (90.6%)
- internal/filter/filter_extra_test.go (91.9%)
- internal/proxy/client_parser_test.go (96.1%)
- internal/render/render_extra_test.go (99.2%)

Overall: 91.8% (4033/4400 statements) — exceeds 85% acceptance threshold.
All tests pass, go vet clean, go build clean.
This commit is contained in:
2026-07-27 16:12:28 +08:00
parent 264b84a738
commit cec95ff4e8
13 changed files with 14161 additions and 12 deletions
+763
View File
@@ -0,0 +1,763 @@
package database
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"github.com/peterqiu0516/sub-store/internal/model"
)
// --- InitDB ---
func TestInitDB_CreatesDirectoryAndAppliesPragmas(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "subdir", "test.db")
db, err := InitDB(dbPath)
if err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer db.Close()
// Verify WAL journal mode
var mode string
if err := db.Get(&mode, "PRAGMA journal_mode"); err != nil {
t.Fatalf("failed to read journal_mode: %v", err)
}
if mode != "wal" {
t.Errorf("expected journal_mode wal, got %s", mode)
}
// Verify foreign_keys
var fk int
if err := db.Get(&fk, "PRAGMA foreign_keys"); err != nil {
t.Fatalf("failed to read foreign_keys: %v", err)
}
if fk != 1 {
t.Errorf("expected foreign_keys 1, got %d", fk)
}
// Verify busy_timeout
var bt int
if err := db.Get(&bt, "PRAGMA busy_timeout"); err != nil {
t.Fatalf("failed to read busy_timeout: %v", err)
}
if bt != 5000 {
t.Errorf("expected busy_timeout 5000, got %d", bt)
}
// Verify synchronous
var sync string
if err := db.Get(&sync, "PRAGMA synchronous"); err != nil {
t.Fatalf("failed to read synchronous: %v", err)
}
if sync != "1" { // NORMAL = 1
t.Errorf("expected synchronous 1 (NORMAL), got %s", sync)
}
// Verify MaxOpenConns
if db.Stats().MaxOpenConnections != 1 {
t.Errorf("expected MaxOpenConnections 1, got %d", db.Stats().MaxOpenConnections)
}
}
func TestInitDB_RelativePath(t *testing.T) {
db, err := InitDB(":memory:")
if err != nil {
// modernc sqlite may not support :memory: via file: DSN; try temp file instead
t.Skipf("InitDB with :memory: not supported: %v", err)
}
defer db.Close()
}
func TestInitDB_ExistingDirectory(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := InitDB(dbPath)
if err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer db.Close()
}
// --- StartCacheCleaner ---
func TestStartCacheCleaner_RemovesExpiredEntries(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCacheRepo(db)
// Insert an expired entry directly (cached_at in the past)
_, err := db.Exec(
`INSERT INTO source_cache (cache_key, content, metadata, cached_at, ttl) VALUES (?, ?, ?, ?, ?)`,
"expired-by-cleaner", "old content", "{}", time.Now().Unix()-100, 1,
)
if err != nil {
t.Fatalf("insert expired entry: %v", err)
}
// Insert a valid entry
repo.SafePut("valid-by-cleaner", "fresh", map[string]any{}, 3600)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
StartCacheCleaner(ctx, db, 100*time.Millisecond)
time.Sleep(350 * time.Millisecond)
cancel()
time.Sleep(50 * time.Millisecond)
// Expired entry should be gone
var count int
db.Get(&count, "SELECT COUNT(*) FROM source_cache WHERE cache_key = ?", "expired-by-cleaner")
if count != 0 {
t.Errorf("expected expired entry to be cleaned, got count=%d", count)
}
// Valid entry should remain
db.Get(&count, "SELECT COUNT(*) FROM source_cache WHERE cache_key = ?", "valid-by-cleaner")
if count != 1 {
t.Errorf("expected valid entry to remain, got count=%d", count)
}
}
// --- SettingsRepo.Replace ---
func TestSettingsReplace(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSettingsRepo(db)
// Replace with new settings
err := repo.Replace(map[string]any{"appName": "Replaced"})
if err != nil {
t.Fatalf("replace failed: %v", err)
}
settings, _ := repo.Get()
if settings["appName"] != "Replaced" {
t.Errorf("expected appName Replaced, got %v", settings["appName"])
}
// Replace again (should overwrite, not merge)
err = repo.Replace(map[string]any{"theme": "dark"})
if err != nil {
t.Fatalf("replace 2 failed: %v", err)
}
settings, _ = repo.Get()
if _, exists := settings["appName"]; exists {
t.Error("expected appName to be gone after replace (not merge)")
}
if settings["theme"] != "dark" {
t.Errorf("expected theme dark, got %v", settings["theme"])
}
}
// --- CollectionRepo.Delete and Sort ---
func TestCollectionDelete(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCollectionRepo(db)
repo.Upsert(model.CollectionRecord{
ID: "col-del", Name: "Del", SourceIds: []string{}, Filters: []model.FilterRule{},
TemplateId: "acl4ssr-mihomo", Meta: map[string]any{},
})
err := repo.Delete("col-del")
if err != nil {
t.Fatalf("delete failed: %v", err)
}
got, _ := repo.Get("col-del")
if got != nil {
t.Error("expected nil after delete")
}
}
func TestCollectionSort(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCollectionRepo(db)
for _, id := range []string{"a", "b", "c"} {
repo.Upsert(model.CollectionRecord{
ID: id, Name: id, SourceIds: []string{}, Filters: []model.FilterRule{},
TemplateId: "acl4ssr-mihomo", Meta: map[string]any{},
})
}
err := repo.Sort([]string{"c", "b", "a"})
if err != nil {
t.Fatalf("sort failed: %v", err)
}
list, _ := repo.List()
if list[0].ID != "c" || list[1].ID != "b" || list[2].ID != "a" {
t.Errorf("sort order wrong: %s, %s, %s", list[0].ID, list[1].ID, list[2].ID)
}
}
// --- TemplateRepo.Delete ---
func TestTemplateDelete(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewTemplateRepo(db)
repo.Upsert(model.TemplateRecord{
ID: "tmpl-del", Name: "Del", Target: "mihomo", Config: map[string]any{},
})
err := repo.Delete("tmpl-del")
if err != nil {
t.Fatalf("delete failed: %v", err)
}
got, _ := repo.Get("tmpl-del")
if got != nil {
t.Error("expected nil after delete")
}
}
// --- GrantRepo: Get, GetSnapshot, Update, Delete, RestoreFromSnapshot ---
func TestGrantGet(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
got, err := repo.Get(rec.ID)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if got == nil {
t.Fatal("expected non-nil grant")
}
if got.ResourceType != "source" {
t.Errorf("expected resourceType source, got %s", got.ResourceType)
}
if got.ResourceId != "src1" {
t.Errorf("expected resourceId src1, got %s", got.ResourceId)
}
}
func TestGrantGet_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
got, err := repo.Get("nonexistent")
if err != nil {
t.Fatalf("expected nil error for not found, got %v", err)
}
if got != nil {
t.Error("expected nil for not found")
}
}
func TestGrantGetSnapshot(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
snap, err := repo.GetSnapshot(rec.ID)
if err != nil {
t.Fatalf("getSnapshot failed: %v", err)
}
if snap == nil {
t.Fatal("expected non-nil snapshot")
}
if snap["id"] != rec.ID {
t.Errorf("expected id %s, got %v", rec.ID, snap["id"])
}
if snap["resourceType"] != "source" {
t.Errorf("expected resourceType source, got %v", snap["resourceType"])
}
if snap["enabled"] != true {
t.Errorf("expected enabled true, got %v", snap["enabled"])
}
if snap["tokenHash"] == "" {
t.Error("expected non-empty tokenHash")
}
}
func TestGrantGetSnapshot_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
snap, err := repo.GetSnapshot("nonexistent")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if snap != nil {
t.Error("expected nil snapshot for not found")
}
}
func TestGrantUpdate(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
// Disable
enabled := false
updated, err := repo.Update(rec.ID, &enabled, nil)
if err != nil {
t.Fatalf("update failed: %v", err)
}
if updated.Enabled {
t.Error("expected enabled false after update")
}
// Set expiry
exp := int64(9999999999000)
updated, err = repo.Update(rec.ID, nil, &exp)
if err != nil {
t.Fatalf("update expiry failed: %v", err)
}
if updated.ExpiresAt == nil || *updated.ExpiresAt != exp {
t.Errorf("expected expiresAt %d, got %v", exp, updated.ExpiresAt)
}
// Set expiry to 0 -> treated as nil (never expire)
zero := int64(0)
updated, err = repo.Update(rec.ID, nil, &zero)
if err != nil {
t.Fatalf("update expiry zero failed: %v", err)
}
if updated.ExpiresAt != nil {
t.Errorf("expected nil expiresAt for 0, got %v", updated.ExpiresAt)
}
}
func TestGrantUpdate_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
updated, err := repo.Update("nonexistent", nil, nil)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if updated != nil {
t.Error("expected nil for not found")
}
}
func TestGrantDelete(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
rec, _, _ := repo.Create("source", "src1", "mihomo", nil)
err := repo.Delete(rec.ID)
if err != nil {
t.Fatalf("delete failed: %v", err)
}
got, _ := repo.Get(rec.ID)
if got != nil {
t.Error("expected nil after delete")
}
}
func TestGrantRestoreFromSnapshot(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
snapshot := map[string]any{
"id": "restored-grant",
"tokenHash": "abc123",
"resourceType": "collection",
"resourceId": "col1",
"target": "surge",
"enabled": true,
"createdAt": float64(1000),
}
err := repo.RestoreFromSnapshot(snapshot)
if err != nil {
t.Fatalf("restore failed: %v", err)
}
got, _ := repo.Get("restored-grant")
if got == nil {
t.Fatal("expected restored grant")
}
if got.ResourceType != "collection" {
t.Errorf("expected resourceType collection, got %s", got.ResourceType)
}
if got.Target != "surge" {
t.Errorf("expected target surge, got %s", got.Target)
}
}
func TestGrantRestoreFromSnapshot_DefaultsAndExpiry(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
// resourceType not "collection" -> defaults to "source"; enabled false; expiresAt positive
snapshot := map[string]any{
"id": "grant-defaults",
"tokenHash": "hash2",
"resourceType": "source",
"resourceId": "src1",
"target": "",
"enabled": false,
"expiresAt": float64(5000),
}
err := repo.RestoreFromSnapshot(snapshot)
if err != nil {
t.Fatalf("restore failed: %v", err)
}
got, _ := repo.Get("grant-defaults")
if got == nil {
t.Fatal("expected restored grant")
}
if got.ResourceType != "source" {
t.Errorf("expected source, got %s", got.ResourceType)
}
if got.Enabled {
t.Error("expected enabled false")
}
if got.ExpiresAt == nil || *got.ExpiresAt != 5000 {
t.Errorf("expected expiresAt 5000, got %v", got.ExpiresAt)
}
}
func TestGrantRestoreFromSnapshot_NoCreatedAt(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
snapshot := map[string]any{
"id": "grant-no-created",
"tokenHash": "hash3",
"resourceType": "source",
"resourceId": "src1",
}
err := repo.RestoreFromSnapshot(snapshot)
if err != nil {
t.Fatalf("restore failed: %v", err)
}
got, _ := repo.Get("grant-no-created")
if got == nil {
t.Fatal("expected restored grant")
}
if got.CreatedAt == 0 {
t.Error("expected non-zero createdAt (defaulted to now)")
}
}
func TestGrantAuthorizeScoped_EmptyToken(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
if repo.AuthorizeScoped("", "source", "src1", "mihomo") {
t.Error("expected false for empty token")
}
}
func TestGrantAuthorizeScoped_ExpiredGrant(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
exp := time.Now().UnixMilli() - 1000
rec, token, _ := repo.Create("source", "src1", "mihomo", &exp)
_ = rec
if repo.AuthorizeScoped(token, "source", "src1", "mihomo") {
t.Error("expected false for expired grant")
}
}
func TestGrantAuthorizeScoped_UnrestrictedTarget(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewGrantRepo(db)
// target = "" means unrestricted
_, token, _ := repo.Create("source", "src1", "", nil)
if !repo.AuthorizeScoped(token, "source", "src1", "surge") {
t.Error("expected true for unrestricted target")
}
if !repo.AuthorizeScoped(token, "source", "src1", "mihomo") {
t.Error("expected true for unrestricted target (mihomo)")
}
}
// --- RecycleRepo.parseSnapshot edge cases (via List/Get) ---
func TestParseSnapshot_Empty(t *testing.T) {
result := parseSnapshot("")
if len(result) != 0 {
t.Errorf("expected empty map, got %v", result)
}
}
func TestParseSnapshot_InvalidJSON(t *testing.T) {
result := parseSnapshot("not json")
if len(result) != 0 {
t.Errorf("expected empty map for invalid json, got %v", result)
}
}
func TestParseSnapshot_Valid(t *testing.T) {
result := parseSnapshot(`{"key":"value"}`)
if result["key"] != "value" {
t.Errorf("expected key=value, got %v", result)
}
}
func TestRecycleList_Empty(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewRecycleRepo(db)
list, err := repo.List()
if err != nil {
t.Fatalf("list failed: %v", err)
}
if len(list) != 0 {
t.Errorf("expected empty list, got %d", len(list))
}
}
func TestRecycleGet_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewRecycleRepo(db)
got, err := repo.Get("nonexistent")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got != nil {
t.Error("expected nil for not found")
}
}
func TestRecycleArchiveAndDelete_TrimExcess(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewRecycleRepo(db)
// Insert more than MaxRecycleEntries to test trimming
for i := 0; i < 55; i++ {
_, err := repo.ArchiveAndDelete("source", "nonexistent", map[string]any{"i": i}, "SELECT 1", nil)
if err != nil {
t.Fatalf("archive %d failed: %v", i, err)
}
}
list, _ := repo.List()
if len(list) > 50 {
t.Errorf("expected at most 50 entries after trim, got %d", len(list))
}
}
// --- helpers ---
func TestMarshalJSON(t *testing.T) {
if s := marshalJSON(map[string]any{"a": 1}); s != `{"a":1}` {
t.Errorf("expected {\"a\":1}, got %s", s)
}
}
func TestBoolToInt(t *testing.T) {
if boolToInt(true) != 1 {
t.Error("expected 1 for true")
}
if boolToInt(false) != 0 {
t.Error("expected 0 for false")
}
}
func TestJsonUnmarshal(t *testing.T) {
var m map[string]any
jsonUnmarshal(`{"a":1}`, &m)
if m["a"] != float64(1) {
t.Errorf("expected a=1, got %v", m["a"])
}
// Empty string should be a no-op
var m2 map[string]any
jsonUnmarshal("", &m2)
if m2 != nil {
t.Error("expected nil for empty string")
}
}
// --- CacheRepo.Get returns nil on error ---
func TestCacheGet_Miss(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCacheRepo(db)
entry, err := repo.Get("nonexistent")
if err != nil {
t.Errorf("expected nil error for miss, got %v", err)
}
if entry != nil {
t.Error("expected nil entry for miss")
}
}
func TestCachePut_PanicRecovery(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCacheRepo(db)
// SafePut should not panic even with nil metadata (normal case)
repo.SafePut("key", "content", nil, 300)
entry, ok := repo.SafeGet("key")
if !ok {
t.Fatal("expected cache hit")
}
if entry.Content != "content" {
t.Errorf("expected content, got %s", entry.Content)
}
if entry.Metadata == nil {
t.Error("expected non-nil metadata")
}
}
func TestCacheGet_Expired(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCacheRepo(db)
repo.Put("expired", "old", map[string]any{}, 1)
time.Sleep(2 * time.Second)
entry, err := repo.Get("expired")
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
if entry != nil {
t.Error("expected nil for expired")
}
}
// --- SourceRepo additional coverage ---
func TestSourceUpsert_AutoId(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSourceRepo(db)
rec, err := repo.Upsert(model.SourceRecord{
Name: "My Source", Type: "local", Content: "ss://pass@host:port#name",
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
})
if err != nil {
t.Fatalf("upsert failed: %v", err)
}
if rec.ID != "my-source" {
t.Errorf("expected id my-source, got %s", rec.ID)
}
}
func TestSourceUpsert_DefaultTypeRemote(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSourceRepo(db)
repo.Upsert(model.SourceRecord{
ID: "test", Name: "Test", Type: "", URL: "https://example.com/sub",
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
})
// Read back — sourceFromRow normalizes empty type to "remote"
got, _ := repo.Get("test")
if got.Type != "remote" {
t.Errorf("expected type remote, got %s", got.Type)
}
}
func TestSourceGet_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewSourceRepo(db)
got, err := repo.Get("nonexistent")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got != nil {
t.Error("expected nil for not found")
}
}
// --- TemplateRepo additional coverage ---
func TestTemplateUpsert_DefaultTargetMihomo(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewTemplateRepo(db)
rec, _ := repo.Upsert(model.TemplateRecord{
ID: "test", Name: "Test", Target: "", Config: nil,
})
if rec.Target != "mihomo" {
t.Errorf("expected target mihomo, got %s", rec.Target)
}
if rec.Config == nil {
t.Error("expected non-nil config")
}
}
func TestTemplateGet_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewTemplateRepo(db)
got, err := repo.Get("nonexistent")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got != nil {
t.Error("expected nil for not found")
}
}
func TestTemplateFromRow_EmptyTarget(t *testing.T) {
rec := templateFromRow(templateRow{ID: "x", Target: "", ConfigJSON: "{}"})
if rec.Target != "mihomo" {
t.Errorf("expected mihomo, got %s", rec.Target)
}
}
// --- CollectionRepo additional coverage ---
func TestCollectionUpsert_Defaults(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCollectionRepo(db)
rec, _ := repo.Upsert(model.CollectionRecord{
ID: "test", Name: "Test",
})
if rec.TemplateId != "acl4ssr-mihomo" {
t.Errorf("expected default template, got %s", rec.TemplateId)
}
if rec.SourceIds == nil {
t.Error("expected non-nil SourceIds")
}
if rec.Filters == nil {
t.Error("expected non-nil Filters")
}
if rec.Meta == nil {
t.Error("expected non-nil Meta")
}
}
func TestCollectionGet_NotFound(t *testing.T) {
db := testDB(t)
defer db.Close()
repo := NewCollectionRepo(db)
got, err := repo.Get("nonexistent")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got != nil {
t.Error("expected nil for not found")
}
}
// --- RunMigrations is already tested via testDB; test error case ---
func TestRunMigrations_OnFreshDB(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "fresh.db")
db, err := sqlx.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open failed: %v", err)
}
defer db.Close()
if err := RunMigrations(db); err != nil {
t.Fatalf("migrations failed: %v", err)
}
// Tables should exist
var count int
db.Get(&count, "SELECT COUNT(*) FROM sources")
if count != 0 {
t.Errorf("expected 0 sources, got %d", count)
}
}