diff --git a/coverage_test.go b/coverage_test.go index 840d9e3..19d4d2f 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -1,7 +1,11 @@ package store import ( + "context" "database/sql" + "database/sql/driver" + "io" + "sync" "testing" core "dappco.re/go/core" @@ -215,3 +219,402 @@ func TestCoverage_Render_Bad_RowsError(t *testing.T) { require.Error(t, err, "Render should fail on corrupted database pages") assert.Contains(t, err.Error(), "store.All: rows") } + +// --------------------------------------------------------------------------- +// GroupsSeq — defensive error paths +// --------------------------------------------------------------------------- + +func TestCoverage_GroupsSeq_Bad_ScanError(t *testing.T) { + // Trigger a scan error by inserting a row with a NULL group name. The + // production code scans into a plain string, which cannot represent NULL. + s, err := New(":memory:") + require.NoError(t, err) + defer s.Close() + + _, err = s.database.Exec("ALTER TABLE entries RENAME TO entries_backup") + require.NoError(t, err) + _, err = s.database.Exec(`CREATE TABLE entries ( + group_name TEXT, + entry_key TEXT, + entry_value TEXT, + expires_at INTEGER + )`) + require.NoError(t, err) + _, err = s.database.Exec("INSERT INTO entries SELECT * FROM entries_backup") + require.NoError(t, err) + _, err = s.database.Exec("INSERT INTO entries (group_name, entry_key, entry_value) VALUES (NULL, 'k', 'v')") + require.NoError(t, err) + _, err = s.database.Exec("DROP TABLE entries_backup") + require.NoError(t, err) + + for groupName, iterationErr := range s.GroupsSeq("") { + require.Error(t, iterationErr) + assert.Empty(t, groupName) + break + } +} + +func TestCoverage_GroupsSeq_Bad_RowsError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + groupRows: [][]driver.Value{ + {"group-a"}, + }, + groupRowsErr: core.E("stubSQLiteScenario", "rows iteration failed", nil), + groupRowsErrIndex: 0, + }) + defer database.Close() + + storeInstance := &Store{ + database: database, + cancelPurge: func() {}, + } + + for groupName, iterationErr := range storeInstance.GroupsSeq("") { + require.Error(t, iterationErr, "GroupsSeq should fail on corrupted database pages") + assert.Empty(t, groupName) + break + } +} + +// --------------------------------------------------------------------------- +// Stubbed SQLite driver coverage +// --------------------------------------------------------------------------- + +func TestCoverage_EnsureSchema_Bad_TableExistsQueryError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableExistsErr: core.E("stubSQLiteScenario", "sqlite master query failed", nil), + }) + defer database.Close() + + err := ensureSchema(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "sqlite master query failed") +} + +func TestCoverage_EnsureSchema_Good_ExistingEntriesAndLegacyMigration(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableExistsFound: true, + tableInfoRows: [][]driver.Value{ + {0, "expires_at", "INTEGER", 0, nil, 0}, + }, + }) + defer database.Close() + + require.NoError(t, ensureSchema(database)) +} + +func TestCoverage_EnsureSchema_Bad_ExpiryColumnQueryError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableExistsFound: true, + tableInfoErr: core.E("stubSQLiteScenario", "table_info query failed", nil), + }) + defer database.Close() + + err := ensureSchema(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "table_info query failed") +} + +func TestCoverage_EnsureSchema_Bad_MigrationError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableExistsFound: true, + tableInfoRows: [][]driver.Value{ + {0, "expires_at", "INTEGER", 0, nil, 0}, + }, + insertErr: core.E("stubSQLiteScenario", "insert failed", nil), + }) + defer database.Close() + + err := ensureSchema(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "insert failed") +} + +func TestCoverage_EnsureSchema_Bad_MigrationCommitError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableExistsFound: true, + tableInfoRows: [][]driver.Value{ + {0, "expires_at", "INTEGER", 0, nil, 0}, + }, + commitErr: core.E("stubSQLiteScenario", "commit failed", nil), + }) + defer database.Close() + + err := ensureSchema(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "commit failed") +} + +func TestCoverage_TableHasColumn_Bad_QueryError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableInfoErr: core.E("stubSQLiteScenario", "table_info query failed", nil), + }) + defer database.Close() + + _, err := tableHasColumn(database, "entries", "expires_at") + require.Error(t, err) + assert.Contains(t, err.Error(), "table_info query failed") +} + +func TestCoverage_EnsureExpiryColumn_Good_DuplicateColumn(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableInfoRows: [][]driver.Value{ + {0, "entry_key", "TEXT", 1, nil, 0}, + }, + alterTableErr: core.E("stubSQLiteScenario", "duplicate column name: expires_at", nil), + }) + defer database.Close() + + require.NoError(t, ensureExpiryColumn(database)) +} + +func TestCoverage_EnsureExpiryColumn_Bad_AlterTableError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableInfoRows: [][]driver.Value{ + {0, "entry_key", "TEXT", 1, nil, 0}, + }, + alterTableErr: core.E("stubSQLiteScenario", "permission denied", nil), + }) + defer database.Close() + + err := ensureExpiryColumn(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestCoverage_MigrateLegacyEntriesTable_Bad_InsertError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableInfoRows: [][]driver.Value{ + {0, "grp", "TEXT", 1, nil, 0}, + }, + insertErr: core.E("stubSQLiteScenario", "insert failed", nil), + }) + defer database.Close() + + err := migrateLegacyEntriesTable(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "insert failed") +} + +func TestCoverage_MigrateLegacyEntriesTable_Bad_BeginError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + beginErr: core.E("stubSQLiteScenario", "begin failed", nil), + }) + defer database.Close() + + err := migrateLegacyEntriesTable(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "begin failed") +} + +func TestCoverage_MigrateLegacyEntriesTable_Good_CreatesAndMigratesLegacyRows(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableInfoRows: [][]driver.Value{ + {0, "grp", "TEXT", 1, nil, 0}, + }, + }) + defer database.Close() + + require.NoError(t, migrateLegacyEntriesTable(database)) +} + +func TestCoverage_MigrateLegacyEntriesTable_Bad_TableInfoError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{ + tableInfoErr: core.E("stubSQLiteScenario", "table_info query failed", nil), + }) + defer database.Close() + + err := migrateLegacyEntriesTable(database) + require.Error(t, err) + assert.Contains(t, err.Error(), "table_info query failed") +} + +type stubSQLiteScenario struct { + tableExistsErr error + tableExistsFound bool + tableInfoErr error + tableInfoRows [][]driver.Value + groupRows [][]driver.Value + groupRowsErr error + groupRowsErrIndex int + alterTableErr error + createTableErr error + insertErr error + dropTableErr error + beginErr error + commitErr error + rollbackErr error +} + +type stubSQLiteDriver struct{} + +type stubSQLiteConn struct { + scenario *stubSQLiteScenario +} + +type stubSQLiteTx struct { + scenario *stubSQLiteScenario +} + +type stubSQLiteRows struct { + columns []string + rows [][]driver.Value + index int + nextErr error + nextErrIndex int +} + +type stubSQLiteResult struct{} + +var ( + stubSQLiteDriverOnce sync.Once + stubSQLiteScenarios sync.Map +) + +const stubSQLiteDriverName = "stub-sqlite" + +func openStubSQLiteDatabase(t *testing.T, scenario stubSQLiteScenario) (*sql.DB, string) { + t.Helper() + + stubSQLiteDriverOnce.Do(func() { + sql.Register(stubSQLiteDriverName, stubSQLiteDriver{}) + }) + + databasePath := t.Name() + stubSQLiteScenarios.Store(databasePath, &scenario) + t.Cleanup(func() { + stubSQLiteScenarios.Delete(databasePath) + }) + + database, err := sql.Open(stubSQLiteDriverName, databasePath) + require.NoError(t, err) + return database, databasePath +} + +func (stubSQLiteDriver) Open(databasePath string) (driver.Conn, error) { + scenarioValue, ok := stubSQLiteScenarios.Load(databasePath) + if !ok { + return nil, core.E("stubSQLiteDriver.Open", "missing scenario", nil) + } + return &stubSQLiteConn{scenario: scenarioValue.(*stubSQLiteScenario)}, nil +} + +func (conn *stubSQLiteConn) Prepare(query string) (driver.Stmt, error) { + return nil, core.E("stubSQLiteConn.Prepare", "not implemented", nil) +} + +func (conn *stubSQLiteConn) Close() error { + return nil +} + +func (conn *stubSQLiteConn) Begin() (driver.Tx, error) { + return conn.BeginTx(context.Background(), driver.TxOptions{}) +} + +func (conn *stubSQLiteConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + if conn.scenario.beginErr != nil { + return nil, conn.scenario.beginErr + } + return &stubSQLiteTx{scenario: conn.scenario}, nil +} + +func (conn *stubSQLiteConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + switch { + case core.Contains(query, "ALTER TABLE entries ADD COLUMN expires_at INTEGER"): + if conn.scenario.alterTableErr != nil { + return nil, conn.scenario.alterTableErr + } + case core.Contains(query, "CREATE TABLE IF NOT EXISTS entries"): + if conn.scenario.createTableErr != nil { + return nil, conn.scenario.createTableErr + } + case core.Contains(query, "INSERT OR IGNORE INTO entries"): + if conn.scenario.insertErr != nil { + return nil, conn.scenario.insertErr + } + case core.Contains(query, "DROP TABLE kv"): + if conn.scenario.dropTableErr != nil { + return nil, conn.scenario.dropTableErr + } + } + return stubSQLiteResult{}, nil +} + +func (conn *stubSQLiteConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + switch { + case core.Contains(query, "sqlite_master"): + if conn.scenario.tableExistsErr != nil { + return nil, conn.scenario.tableExistsErr + } + if conn.scenario.tableExistsFound { + return &stubSQLiteRows{ + columns: []string{"name"}, + rows: [][]driver.Value{{"entries"}}, + }, nil + } + return &stubSQLiteRows{columns: []string{"name"}}, nil + case core.Contains(query, "SELECT DISTINCT "+entryGroupColumn): + return &stubSQLiteRows{ + columns: []string{entryGroupColumn}, + rows: conn.scenario.groupRows, + nextErr: conn.scenario.groupRowsErr, + nextErrIndex: conn.scenario.groupRowsErrIndex, + }, nil + case core.HasPrefix(query, "PRAGMA table_info("): + if conn.scenario.tableInfoErr != nil { + return nil, conn.scenario.tableInfoErr + } + return &stubSQLiteRows{ + columns: []string{"cid", "name", "type", "notnull", "dflt_value", "pk"}, + rows: conn.scenario.tableInfoRows, + }, nil + } + return nil, core.E("stubSQLiteConn.QueryContext", "unexpected query", nil) +} + +func (tx *stubSQLiteTx) Commit() error { + if tx.scenario.commitErr != nil { + return tx.scenario.commitErr + } + return nil +} + +func (tx *stubSQLiteTx) Rollback() error { + if tx.scenario.rollbackErr != nil { + return tx.scenario.rollbackErr + } + return nil +} + +func (rows *stubSQLiteRows) Columns() []string { + return rows.columns +} + +func (rows *stubSQLiteRows) Close() error { + return nil +} + +func (rows *stubSQLiteRows) Next(dest []driver.Value) error { + if rows.nextErr != nil && rows.index == rows.nextErrIndex { + rows.index++ + return rows.nextErr + } + if rows.index >= len(rows.rows) { + return io.EOF + } + row := rows.rows[rows.index] + rows.index++ + for i := range dest { + dest[i] = nil + } + copy(dest, row) + return nil +} + +func (stubSQLiteResult) LastInsertId() (int64, error) { + return 0, nil +} + +func (stubSQLiteResult) RowsAffected() (int64, error) { + return 0, nil +} diff --git a/events_test.go b/events_test.go index 18e9aee..ffaaf44 100644 --- a/events_test.go +++ b/events_test.go @@ -66,6 +66,23 @@ func TestEvents_Watch_Good_WildcardKey(t *testing.T) { assert.Equal(t, "colour", received[1].Key) } +func TestEvents_Watch_Good_GroupMismatch(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + w := s.Watch("config", "*") + defer s.Unwatch(w) + + require.NoError(t, s.Set("other", "theme", "dark")) + + select { + case e := <-w.Events: + t.Fatalf("unexpected event for non-matching group: %+v", e) + case <-time.After(50 * time.Millisecond): + // Expected: no event. + } +} + // --------------------------------------------------------------------------- // Watch — wildcard ("*", "*") matches everything // --------------------------------------------------------------------------- @@ -120,6 +137,13 @@ func TestEvents_Unwatch_Good_Idempotent(t *testing.T) { s.Unwatch(w) // second call is a no-op } +func TestEvents_Unwatch_Good_NilWatcher(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + s.Unwatch(nil) +} + // --------------------------------------------------------------------------- // Delete triggers event // --------------------------------------------------------------------------- diff --git a/scope_test.go b/scope_test.go index d5a4512..10d21cc 100644 --- a/scope_test.go +++ b/scope_test.go @@ -55,6 +55,15 @@ func TestScope_NewScoped_Bad_InvalidChars(t *testing.T) { } } +func TestScope_NewScopedWithQuota_Bad_InvalidNamespace(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _, err := NewScopedWithQuota(s, "tenant_a", QuotaConfig{MaxKeys: 1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "store.NewScoped") +} + // --------------------------------------------------------------------------- // ScopedStore — basic CRUD // --------------------------------------------------------------------------- @@ -153,6 +162,23 @@ func TestScope_ScopedStore_Good_GetAll(t *testing.T) { assert.Equal(t, map[string]string{"z": "3"}, allB) } +func TestScope_ScopedStore_Good_All(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + scopedStore, _ := NewScoped(s, "tenant-a") + require.NoError(t, scopedStore.Set("items", "first", "1")) + require.NoError(t, scopedStore.Set("items", "second", "2")) + + var keys []string + for entry, err := range scopedStore.All("items") { + require.NoError(t, err) + keys = append(keys, entry.Key) + } + + assert.ElementsMatch(t, []string{"first", "second"}, keys) +} + func TestScope_ScopedStore_Good_Count(t *testing.T) { s, _ := New(":memory:") defer s.Close() @@ -224,6 +250,23 @@ func TestScope_Quota_Good_MaxKeys(t *testing.T) { assert.True(t, core.Is(err, QuotaExceededError), "expected QuotaExceededError, got: %v", err) } +func TestScope_Quota_Bad_QuotaCheckQueryError(t *testing.T) { + database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{}) + defer database.Close() + + storeInstance := &Store{ + database: database, + cancelPurge: func() {}, + } + + scopedStore, err := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 1}) + require.NoError(t, err) + + err = scopedStore.Set("config", "theme", "dark") + require.Error(t, err) + assert.Contains(t, err.Error(), "quota check") +} + func TestScope_Quota_Good_MaxKeys_AcrossGroups(t *testing.T) { s, _ := New(":memory:") defer s.Close() diff --git a/store_test.go b/store_test.go index a6555e5..c85966d 100644 --- a/store_test.go +++ b/store_test.go @@ -382,6 +382,24 @@ func TestStore_GroupsSeq_Good_StopsEarly(t *testing.T) { assert.Len(t, seen, 1) } +func TestStore_GroupsSeq_Good_PrefixStopsEarly(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + require.NoError(t, s.Set("alpha", "a", "1")) + require.NoError(t, s.Set("beta", "b", "2")) + + groups := s.GroupsSeq("alpha") + var seen []string + for group, err := range groups { + require.NoError(t, err) + seen = append(seen, group) + break + } + + assert.Equal(t, []string{"alpha"}, seen) +} + func TestStore_GroupsSeq_Bad_ClosedStore(t *testing.T) { s, _ := New(":memory:") s.Close()