using System.Text.Json;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Database;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Database;
///
/// #676: "There is no field for it" must be a finding, not a guess. The tool describes a table,
/// lists the tables, and a refused statement names what exists instead.
///
public sealed class SchemaLookupTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "schema-" + Guid.NewGuid().ToString("N")[..8]);
private readonly string _connectionString;
private readonly DatabaseTool _tool = new();
public SchemaLookupTests()
{
Directory.CreateDirectory(_directory);
// Without pooling the file is free again when the test ends.
_connectionString = $"Data Source={Path.Combine(_directory, "shop.db")};Pooling=False";
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE messwerte (gemessen_um TEXT NOT NULL, ort TEXT, temperatur_c REAL, luftfeuchte_pct REAL);
INSERT INTO messwerte VALUES ('2027-03-10 12:00', 'Lager', 18.5, 40);
CREATE TABLE geheim (wert TEXT);
""";
cmd.ExecuteNonQuery();
}
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private AgentToolContext Context(params string[] allowed) => new(
"datenleser", "inst",
new Dictionary
{
["type"] = "sqlite",
["connectionString"] = _connectionString,
["allowedTables"] = JsonSerializer.SerializeToElement(allowed)
},
new NullStateStore(), NullLogger.Instance, CancellationToken.None);
private Task Run(object input, params string[] allowed)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(allowed.Length == 0 ? ["messwerte", "bestellungen"] : allowed), default);
[Fact]
public async Task Describe_lists_every_column_and_says_that_is_all()
{
var result = await Run(new { action = "describe", table = "messwerte" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("Columns of 'messwerte' (4)");
result.Content.ShouldContain("- gemessen_um TEXT not null");
result.Content.ShouldContain("A field that is not listed does not exist.");
}
[Fact]
public async Task A_table_that_is_not_released_is_not_described()
{
var result = await Run(new { action = "describe", table = "geheim" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldNotContain("wert TEXT", Case.Sensitive, "the schema of a forbidden table stays hidden");
}
[Fact]
public async Task A_released_table_that_does_not_exist_is_said_to_be_missing()
{
var result = await Run(new { action = "describe", table = "bestellungen" });
result.ErrorMessage!.ShouldContain("does not exist");
result.ErrorMessage.ShouldContain("Tables you may use: messwerte");
}
[Fact]
public async Task Tables_names_what_exists_and_what_is_released_but_missing()
{
var result = await Run(new { action = "tables" });
result.Content.ShouldContain("Tables you may use: messwerte.");
result.Content.ShouldContain("not in the database: bestellungen");
result.Content.ShouldNotContain("geheim");
}
[Fact]
public async Task An_unknown_column_is_answered_with_the_columns_that_exist()
{
var result = await Run(new { action = "query", sql = "SELECT zeitpunkt FROM messwerte" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("There is no column 'zeitpunkt'");
result.ErrorMessage.ShouldContain("Columns of 'messwerte': gemessen_um, ort, temperatur_c, luftfeuchte_pct.");
}
[Fact]
public async Task An_unknown_table_is_answered_with_the_tables_that_exist()
{
var result = await Run(new { action = "query", sql = "SELECT * FROM bestellungen" });
result.ErrorMessage!.ShouldContain("There is no table 'bestellungen'");
result.ErrorMessage.ShouldContain("Tables you may use: messwerte.");
}
[Fact]
public async Task A_plain_query_still_reads()
{
var result = await Run(new { action = "query", sql = "SELECT ort, temperatur_c FROM messwerte" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("Lager");
}
[Fact]
public void Describe_is_a_read_and_is_checked_before_approval()
{
_tool.StagingAktion(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" })).ShouldBe("select");
_tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "geheim" }), Context("messwerte"))
.ShouldNotBeNull();
_tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" }), Context("messwerte"))
.ShouldBeNull();
}
[Theory]
[InlineData("Unknown column 'zeitpunkt' in 'field list'", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("42703: column \"zeitpunkt\" does not exist", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("Invalid column name 'zeitpunkt'.", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("SQLite Error 1: 'no such column: zeitpunkt'.", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("Table 'shop.bestellungen' doesn't exist", SchemaMiss.UnknownTable, "shop.bestellungen")]
[InlineData("42P01: relation \"bestellungen\" does not exist", SchemaMiss.UnknownTable, "bestellungen")]
[InlineData("Invalid object name 'bestellungen'.", SchemaMiss.UnknownTable, "bestellungen")]
[InlineData("Duplicate entry '1' for key 'PRIMARY'", SchemaMiss.None, null)]
public void The_refusal_of_each_database_is_read(string message, SchemaMiss miss, string? name)
{
var (found, what) = SchemaLookup.Classify(message);
found.ShouldBe(miss);
what.ShouldBe(name);
}
}