package main import ( "os" "path/filepath" "strings" "testing" "time" ) func TestModelStoreSQLiteCreatesSchemaAndPersists(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "models.sqlite") store := NewModelStore(ModelStoreConfig{ Type: databaseTypeSQLite, DSN: dbPath, SQLitePath: dbPath, }) defer store.Close() if err := store.Load(); err != nil { t.Fatalf("load sqlite store: %v", err) } if _, err := os.Stat(dbPath); err != nil { t.Fatalf("sqlite file not created: %v", err) } model, err := store.EnsureByHostModelKey("chaturbate.com", "alice") if err != nil { t.Fatalf("ensure model: %v", err) } if model.ID != "chaturbate.com:alice" { t.Fatalf("unexpected model id: %q", model.ID) } if err := store.SetLastSeenOnline("chaturbate.com", "alice", true, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { t.Fatalf("set last seen online: %v", err) } if err := store.flushPendingStatusUpdates(); err != nil { t.Fatalf("flush status updates: %v", err) } models := store.List() if len(models) != 1 { t.Fatalf("expected 1 model, got %d", len(models)) } if models[0].LastSeenOnline == nil || !*models[0].LastSeenOnline { t.Fatalf("lastSeenOnline not persisted: %#v", models[0].LastSeenOnline) } meta := store.Meta() if meta.Count != 1 { t.Fatalf("expected meta count 1, got %d", meta.Count) } if meta.UpdatedAt == "" { t.Fatal("expected meta updatedAt") } } func TestModelStoreSQLiteStoresProfileImagesAsFiles(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "models.sqlite") store := NewModelStore(ModelStoreConfig{ Type: databaseTypeSQLite, DSN: dbPath, SQLitePath: dbPath, }) defer store.Close() if err := store.Load(); err != nil { t.Fatalf("load sqlite store: %v", err) } if _, err := store.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil { t.Fatalf("ensure model: %v", err) } data := []byte{0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00} if err := store.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", data, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { t.Fatalf("set profile image: %v", err) } var relPath, hash string var blob []byte if err := store.db.QueryRow(`SELECT profile_image_path, profile_image_hash, profile_image_blob FROM models WHERE id=$1;`, "chaturbate.com:alice").Scan(&relPath, &hash, &blob); err != nil { t.Fatalf("query profile image fields: %v", err) } if strings.TrimSpace(relPath) == "" { t.Fatal("expected profile image path") } if strings.TrimSpace(hash) == "" { t.Fatal("expected profile image hash") } if len(blob) != 0 { t.Fatalf("expected sqlite blob cleared, got %d bytes", len(blob)) } absPath, err := store.resolveProfileImagePath(relPath) if err != nil { t.Fatalf("resolve profile image path: %v", err) } if b, err := os.ReadFile(absPath); err != nil { t.Fatalf("read profile image file: %v", err) } else if string(b) != string(data) { t.Fatalf("unexpected profile image data: %v", b) } mime, got, ok, err := store.GetProfileImageByID("chaturbate.com:alice") if err != nil { t.Fatalf("get profile image: %v", err) } if !ok || mime != "image/jpeg" || string(got) != string(data) { t.Fatalf("unexpected profile image response ok=%v mime=%q data=%v", ok, mime, got) } } func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) { tmp := t.TempDir() sourcePath := filepath.Join(tmp, "source.sqlite") targetPath := filepath.Join(tmp, "target.sqlite") sourceConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: sourcePath, SQLitePath: sourcePath} targetConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: targetPath, SQLitePath: targetPath} source := NewModelStore(sourceConfig) defer source.Close() if err := source.Load(); err != nil { t.Fatalf("load source: %v", err) } if _, err := source.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil { t.Fatalf("ensure source model: %v", err) } if err := source.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", []byte{1, 2, 3, 4}, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { t.Fatalf("set profile image: %v", err) } if err := source.SetBioContext("chaturbate.com", "alice", `{"ok":true}`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { t.Fatalf("set bio context: %v", err) } target := NewModelStore(targetConfig) if err := target.Load(); err != nil { t.Fatalf("load target: %v", err) } if _, err := target.EnsureByHostModelKey("chaturbate.com", "stale"); err != nil { t.Fatalf("ensure stale target model: %v", err) } _ = target.Close() _ = source.Close() count, err := migrateModelStoreData(sourceConfig, targetConfig) if err != nil { t.Fatalf("migrate sqlite to sqlite: %v", err) } if count != 1 { t.Fatalf("expected 1 migrated row, got %d", count) } migrated := NewModelStore(targetConfig) defer migrated.Close() if err := migrated.Load(); err != nil { t.Fatalf("load migrated target: %v", err) } models := migrated.List() if len(models) != 1 { t.Fatalf("expected target replacement with 1 row, got %d", len(models)) } if models[0].ModelKey != "alice" { t.Fatalf("unexpected migrated model: %q", models[0].ModelKey) } if models[0].ProfileImageCached == "" { t.Fatal("expected migrated profile image blob marker") } bio, _, ok, err := migrated.GetBioContext("chaturbate.com", "alice") if err != nil { t.Fatalf("get migrated bio context: %v", err) } if !ok || bio != `{"ok":true}` { t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio) } } func TestMoveSQLiteDatabaseFilesMovesSidecars(t *testing.T) { tmp := t.TempDir() sourcePath := filepath.Join(tmp, "old", "models.sqlite") targetPath := filepath.Join(tmp, "new", "models.sqlite") if err := os.MkdirAll(filepath.Dir(sourcePath), 0755); err != nil { t.Fatalf("mkdir source: %v", err) } files := map[string]string{ sourcePath: "main", sourcePath + "-wal": "wal", sourcePath + "-shm": "shm", sourcePath + "-journal": "journal", } for path, content := range files { if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write %s: %v", path, err) } } sourceImageDir := filepath.Join(filepath.Dir(sourcePath), modelProfileImageDirName) sourceImagePath := filepath.Join(sourceImageDir, "aa", "avatar.jpg") if err := os.MkdirAll(filepath.Dir(sourceImagePath), 0755); err != nil { t.Fatalf("mkdir profile image dir: %v", err) } if err := os.WriteFile(sourceImagePath, []byte("image"), 0644); err != nil { t.Fatalf("write profile image: %v", err) } if err := moveSQLiteDatabaseFiles(sourcePath, targetPath); err != nil { t.Fatalf("move sqlite files: %v", err) } for source, content := range files { if _, err := os.Stat(source); !os.IsNotExist(err) { t.Fatalf("expected source removed %s, err=%v", source, err) } target := strings.Replace(source, sourcePath, targetPath, 1) b, err := os.ReadFile(target) if err != nil { t.Fatalf("read target %s: %v", target, err) } if string(b) != content { t.Fatalf("unexpected target content for %s: %q", target, string(b)) } } if _, err := os.Stat(sourceImagePath); !os.IsNotExist(err) { t.Fatalf("expected source profile image moved, err=%v", err) } targetImagePath := strings.Replace(sourceImagePath, filepath.Dir(sourcePath), filepath.Dir(targetPath), 1) b, err := os.ReadFile(targetImagePath) if err != nil { t.Fatalf("read target profile image: %v", err) } if string(b) != "image" { t.Fatalf("unexpected target profile image content: %q", string(b)) } }