2026-07-25 14:47:21 +00:00
package db_test
import (
"bytes"
"context"
"database/sql"
"errors"
"testing"
)
// Plugin persistence (migration 015) and the per-plugin KV namespace had no
// coverage. The KV namespace is the isolation boundary between plugins — the
// primary key is (plugin_id, key), so a plugin cannot name another plugin's
// namespace — and that property is worth pinning explicitly.
func installTestPlugin ( t * testing . T , database interface {
InstallPlugin ( ctx context . Context , name , version , manifestJSON string ) ( int64 , error )
}, name string ) int64 {
t . Helper ()
id , err := database . InstallPlugin ( context . Background (), name , "1.0.0" , `{"name":"` + name + `"}` )
if err != nil {
t . Fatalf ( "InstallPlugin(%s): %v" , name , err )
}
return id
}
func TestInstallPlugin_AndGetPlugin ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
if id == 0 {
t . Fatal ( "InstallPlugin returned id 0" )
}
got , err := database . GetPlugin ( ctx , id )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if got . Name != "hello" {
t . Errorf ( "Name = %q, want %q" , got . Name , "hello" )
}
if got . Version != "1.0.0" {
t . Errorf ( "Version = %q, want %q" , got . Version , "1.0.0" )
}
if got . Enabled {
t . Error ( "Enabled = true; a freshly installed plugin must default to disabled" )
}
if got . InstalledAt . IsZero () {
t . Error ( "InstalledAt is zero; scanPluginRow should have parsed the timestamp" )
}
}
func TestInstallPlugin_ReinstallUpdatesInPlace ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
first := installTestPlugin ( t , database , "hello" )
// ON CONFLICT(name) DO UPDATE — a reinstall must upgrade the existing row
// rather than create a second one, and must return the same id (the path
// where LastInsertId is 0 and the code falls back to a lookup by name).
second , err := database . InstallPlugin ( ctx , "hello" , "2.0.0" , `{"name":"hello","v":2}` )
if err != nil {
t . Fatalf ( "InstallPlugin reinstall: %v" , err )
}
if second != first {
t . Errorf ( "reinstall returned id %d, want the original %d" , second , first )
}
got , err := database . GetPlugin ( ctx , first )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if got . Version != "2.0.0" {
t . Errorf ( "Version = %q after reinstall, want %q" , got . Version , "2.0.0" )
}
all , err := database . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
if len ( all ) != 1 {
t . Errorf ( "ListPlugins len = %d after reinstall, want 1" , len ( all ))
}
}
func TestEnableDisablePlugin ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
if err := database . EnablePlugin ( ctx , id ); err != nil {
t . Fatalf ( "EnablePlugin: %v" , err )
}
got , err := database . GetPlugin ( ctx , id )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if ! got . Enabled {
t . Error ( "Enabled = false after EnablePlugin" )
}
if err := database . DisablePlugin ( ctx , id ); err != nil {
t . Fatalf ( "DisablePlugin: %v" , err )
}
got , err = database . GetPlugin ( ctx , id )
if err != nil {
t . Fatalf ( "GetPlugin: %v" , err )
}
if got . Enabled {
t . Error ( "Enabled = true after DisablePlugin" )
}
}
func TestGetPluginByName ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
got , err := database . GetPluginByName ( ctx , "hello" )
if err != nil {
t . Fatalf ( "GetPluginByName: %v" , err )
}
if got . ID != id {
t . Errorf ( "ID = %d, want %d" , got . ID , id )
}
_ , err = database . GetPluginByName ( ctx , "does-not-exist" )
if ! errors . Is ( err , sql . ErrNoRows ) {
t . Errorf ( "GetPluginByName on a missing name = %v, want sql.ErrNoRows" , err )
}
}
func TestGetPlugin_Missing ( t * testing . T ) {
database := newMigratedTestDB ( t )
_ , err := database . GetPlugin ( context . Background (), 4242 )
if ! errors . Is ( err , sql . ErrNoRows ) {
t . Errorf ( "GetPlugin on a missing id = %v, want sql.ErrNoRows" , err )
}
}
func TestUninstallPlugin_CascadesKV ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
if err := database . PluginKVSet ( ctx , id , "k" , [] byte ( "v" )); err != nil {
t . Fatalf ( "PluginKVSet: %v" , err )
}
if err := database . UninstallPlugin ( ctx , id ); err != nil {
t . Fatalf ( "UninstallPlugin: %v" , err )
}
if _ , err := database . GetPlugin ( ctx , id ); ! errors . Is ( err , sql . ErrNoRows ) {
t . Errorf ( "GetPlugin after uninstall = %v, want sql.ErrNoRows" , err )
}
// plugin_kv has ON DELETE CASCADE; an uninstall must not leave the removed
// plugin's stored data behind for whatever reinstalls under that id.
var leftover int
row := database . QueryRowContext ( ctx , `SELECT COUNT(*) FROM plugin_kv WHERE plugin_id = ?` , id )
if err := row . Scan ( & leftover ); err != nil {
t . Fatalf ( "count plugin_kv: %v" , err )
}
if leftover != 0 {
t . Errorf ( "%d plugin_kv rows survived the uninstall, want 0" , leftover )
}
}
func TestListPlugins_OrderedByName ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
empty , err := database . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins on empty: %v" , err )
}
if len ( empty ) != 0 {
t . Errorf ( "ListPlugins on empty = %v, want none" , empty )
}
for _ , name := range [] string { "zeta" , "alpha" , "mid" } {
installTestPlugin ( t , database , name )
}
got , err := database . ListPlugins ( ctx )
if err != nil {
t . Fatalf ( "ListPlugins: %v" , err )
}
want := [] string { "alpha" , "mid" , "zeta" }
if len ( got ) != len ( want ) {
t . Fatalf ( "ListPlugins len = %d, want %d" , len ( got ), len ( want ))
}
for i , w := range want {
if got [ i ]. Name != w {
t . Errorf ( "ListPlugins[%d].Name = %q, want %q" , i , got [ i ]. Name , w )
}
}
}
func TestPluginKV_SetGetDelete ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
if _ , err := database . PluginKVGet ( ctx , id , "absent" ); ! errors . Is ( err , sql . ErrNoRows ) {
t . Errorf ( "PluginKVGet on a missing key = %v, want sql.ErrNoRows" , err )
}
if err := database . PluginKVSet ( ctx , id , "greeting" , [] byte ( "hi" )); err != nil {
t . Fatalf ( "PluginKVSet: %v" , err )
}
v , err := database . PluginKVGet ( ctx , id , "greeting" )
if err != nil {
t . Fatalf ( "PluginKVGet: %v" , err )
}
if ! bytes . Equal ( v , [] byte ( "hi" )) {
t . Errorf ( "value = %q, want %q" , v , "hi" )
}
// ON CONFLICT(plugin_id, key) DO UPDATE — a second Set overwrites.
if err := database . PluginKVSet ( ctx , id , "greeting" , [] byte ( "hello again" )); err != nil {
t . Fatalf ( "PluginKVSet overwrite: %v" , err )
}
v , err = database . PluginKVGet ( ctx , id , "greeting" )
if err != nil {
t . Fatalf ( "PluginKVGet after overwrite: %v" , err )
}
if ! bytes . Equal ( v , [] byte ( "hello again" )) {
t . Errorf ( "value = %q after overwrite, want %q" , v , "hello again" )
}
if err := database . PluginKVDelete ( ctx , id , "greeting" ); err != nil {
t . Fatalf ( "PluginKVDelete: %v" , err )
}
if _ , err := database . PluginKVGet ( ctx , id , "greeting" ); ! errors . Is ( err , sql . ErrNoRows ) {
t . Errorf ( "PluginKVGet after delete = %v, want sql.ErrNoRows" , err )
}
}
func TestPluginKV_NamespacesAreIsolated ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
a := installTestPlugin ( t , database , "plugin-a" )
b := installTestPlugin ( t , database , "plugin-b" )
if err := database . PluginKVSet ( ctx , a , "secret" , [] byte ( "a-value" )); err != nil {
t . Fatalf ( "PluginKVSet a: %v" , err )
}
if err := database . PluginKVSet ( ctx , b , "secret" , [] byte ( "b-value" )); err != nil {
t . Fatalf ( "PluginKVSet b: %v" , err )
}
// Same key, different namespaces — neither plugin can read or clobber the
// other's value. This is the whole isolation guarantee of the KV store.
got , err := database . PluginKVGet ( ctx , a , "secret" )
if err != nil {
t . Fatalf ( "PluginKVGet a: %v" , err )
}
if ! bytes . Equal ( got , [] byte ( "a-value" )) {
t . Errorf ( "plugin-a read %q, want %q" , got , "a-value" )
}
if err := database . PluginKVDelete ( ctx , a , "secret" ); err != nil {
t . Fatalf ( "PluginKVDelete a: %v" , err )
}
got , err = database . PluginKVGet ( ctx , b , "secret" )
if err != nil {
t . Fatalf ( "PluginKVGet b after deleting a's key: %v" , err )
}
if ! bytes . Equal ( got , [] byte ( "b-value" )) {
t . Errorf ( "plugin-b value = %q after plugin-a deleted its own key, want %q" , got , "b-value" )
}
}
func TestPluginKVScan ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
other := installTestPlugin ( t , database , "other" )
for k , v := range map [ string ] string {
"cfg:a" : "1" ,
"cfg:b" : "2" ,
"state:x" : "3" ,
} {
if err := database . PluginKVSet ( ctx , id , k , [] byte ( v )); err != nil {
t . Fatalf ( "PluginKVSet(%s): %v" , k , err )
}
}
if err := database . PluginKVSet ( ctx , other , "cfg:a" , [] byte ( "nope" )); err != nil {
t . Fatalf ( "PluginKVSet other: %v" , err )
}
got , err := database . PluginKVScan ( ctx , id , "cfg:" , 100 )
if err != nil {
t . Fatalf ( "PluginKVScan: %v" , err )
}
if len ( got ) != 2 {
t . Fatalf ( "PluginKVScan returned %d entries, want 2: %v" , len ( got ), got )
}
if ! bytes . Equal ( got [ "cfg:a" ], [] byte ( "1" )) || ! bytes . Equal ( got [ "cfg:b" ], [] byte ( "2" )) {
t . Errorf ( "PluginKVScan = %v, want cfg:a=1 and cfg:b=2 from this plugin only" , got )
}
limited , err := database . PluginKVScan ( ctx , id , "cfg:" , 1 )
if err != nil {
t . Fatalf ( "PluginKVScan with limit: %v" , err )
}
if len ( limited ) != 1 {
t . Errorf ( "PluginKVScan with limit 1 returned %d entries, want 1" , len ( limited ))
}
none , err := database . PluginKVScan ( ctx , id , "nomatch:" , 100 )
if err != nil {
t . Fatalf ( "PluginKVScan no match: %v" , err )
}
if len ( none ) != 0 {
t . Errorf ( "PluginKVScan with a non-matching prefix = %v, want empty" , none )
}
}
2026-08-07 21:20:48 +02:00
2026-08-19 16:32:07 +02:00
// TestPluginKVScan_IsBinaryPrefixMatch pins that PluginKVScan is an exact,
// case-sensitive BINARY prefix match — matching the exact-match semantics of
// PluginKVGet/Set/Delete on the same table — rather than a SQL LIKE pattern
// match, where '_' and '%' are wildcards and matching is ASCII
// case-insensitive.
func TestPluginKVScan_IsBinaryPrefixMatch ( t * testing . T ) {
database := newMigratedTestDB ( t )
ctx := context . Background ()
id := installTestPlugin ( t , database , "hello" )
for k , v := range map [ string ] string {
"cfg_a" : "underscore-match" ,
"cfgXa" : "not-a-prefix-match" ,
"Key1" : "capital-key" ,
"key1" : "lowercase-key" ,
} {
if err := database . PluginKVSet ( ctx , id , k , [] byte ( v )); err != nil {
t . Fatalf ( "PluginKVSet(%s): %v" , k , err )
}
}
// '_' in the prefix must be a literal underscore, not a LIKE
// single-character wildcard, so "cfgXa" must not be returned.
underscoreScan , err := database . PluginKVScan ( ctx , id , "cfg_" , 100 )
if err != nil {
t . Fatalf ( "PluginKVScan cfg_: %v" , err )
}
if _ , ok := underscoreScan [ "cfgXa" ]; ok {
t . Errorf ( "PluginKVScan(%q) = %v; '_' matched any character like a LIKE wildcard, but PluginKVGet treats \"cfg_a\" and \"cfgXa\" as distinct keys" , "cfg_" , underscoreScan )
}
if len ( underscoreScan ) != 1 || ! bytes . Equal ( underscoreScan [ "cfg_a" ], [] byte ( "underscore-match" )) {
t . Errorf ( "PluginKVScan(%q) = %v, want exactly {cfg_a: underscore-match}" , "cfg_" , underscoreScan )
}
// Matching must be case-sensitive (BINARY), matching key = ? on the same
// table, so scanning "Key" must not return "key1".
caseScan , err := database . PluginKVScan ( ctx , id , "Key" , 100 )
if err != nil {
t . Fatalf ( "PluginKVScan Key: %v" , err )
}
if _ , ok := caseScan [ "key1" ]; ok {
t . Errorf ( "PluginKVScan(%q) = %v; LIKE's ASCII case-insensitivity matched \"key1\", but PluginKVGet/Delete treat \"Key1\" and \"key1\" as distinct keys" , "Key" , caseScan )
}
if len ( caseScan ) != 1 || ! bytes . Equal ( caseScan [ "Key1" ], [] byte ( "capital-key" )) {
t . Errorf ( "PluginKVScan(%q) = %v, want exactly {Key1: capital-key}" , "Key" , caseScan )
}
}
2026-08-07 21:20:48 +02:00
func TestInstallPlugin_ReinstallReturnsCorrectID_AfterOtherWrites ( t * testing . T ) {
database := openMigratedMemory ( t )
ctx := context . Background ()
id1 , err := database . InstallPlugin ( ctx , "rowid-plugin" , "1.0" , "{}" )
if err != nil {
t . Fatalf ( "InstallPlugin: %v" , err )
}
// Other INSERTs on the shared single writer connection move
// last_insert_rowid past the plugin's id; the upsert's UPDATE branch
// does not reset it.
seedUser ( t , database , "rowid-mover-1" )
seedUser ( t , database , "rowid-mover-2" )
seedUser ( t , database , "rowid-mover-3" )
id2 , err := database . InstallPlugin ( ctx , "rowid-plugin" , "2.0" , "{}" )
if err != nil {
t . Fatalf ( "InstallPlugin reinstall: %v" , err )
}
if id2 != id1 {
t . Errorf ( "reinstall returned id %d, want %d — EnablePlugin/plugin_kv would target a nonexistent row" , id2 , id1 )
}
}