478 lines
16 KiB
C#
478 lines
16 KiB
C#
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Omika.Core.Compatibility;
|
|
using Omika.Core.Runtime;
|
|
using Omika.Data;
|
|
using Omika.Data.Configuration;
|
|
using Omika.Data.Connection;
|
|
using Omika.Data.SchemaDetection;
|
|
|
|
|
|
namespace Omika.Tests.SchemaDetection;
|
|
|
|
[Collection("ApplicationRuntimeContext")]
|
|
public class DatabaseSchemaDetectorTests
|
|
{
|
|
|
|
// --- Métadonnées simulées -------------------------------------------------
|
|
|
|
private static readonly IReadOnlyList<HfsqlColumnMetadata> BatikClientColumns =
|
|
[
|
|
new("Client", "CL_PKID"),
|
|
new("Client", "CL_Nom"),
|
|
new("Client", "CL_IDType")
|
|
];
|
|
|
|
private static readonly IReadOnlyList<HfsqlColumnMetadata> OmikaClientColumns =
|
|
[
|
|
new("Client", "CL_PKID"),
|
|
new("Client", "CL_Nom"),
|
|
new("Client", "CL_IDType"),
|
|
new("Client", "CL_CleType"),
|
|
new("Client", "GUID")
|
|
];
|
|
|
|
private static readonly IReadOnlyList<HfsqlColumnMetadata> RoleColumns =
|
|
[
|
|
new("Role", "R_PKID"),
|
|
new("Role", "R_Libelle")
|
|
];
|
|
|
|
private static readonly IReadOnlyDictionary<string, IReadOnlyList<HfsqlColumnMetadata>> BatikSchema =
|
|
new Dictionary<string, IReadOnlyList<HfsqlColumnMetadata>>
|
|
{
|
|
["Client"] = BatikClientColumns
|
|
};
|
|
|
|
private static readonly IReadOnlyDictionary<string, IReadOnlyList<HfsqlColumnMetadata>> OmikaSchema =
|
|
new Dictionary<string, IReadOnlyList<HfsqlColumnMetadata>>
|
|
{
|
|
["Client"] = OmikaClientColumns,
|
|
["Role"] = RoleColumns
|
|
};
|
|
|
|
// --- Cas 1 : structure Omika complète => Omika ----------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_OmikaComplete_ReturnsOmika()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value);
|
|
}
|
|
|
|
// --- Cas 2 : structure Batik complète => Batik ----------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_BatikComplete_ReturnsBatik()
|
|
{
|
|
var connection = new FakeConnection(BatikSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value);
|
|
}
|
|
|
|
// --- Cas 3 : insensibilité à la casse -------------------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_ColumnNamesDifferentCase_IsCaseInsensitive()
|
|
{
|
|
var schema = new Dictionary<string, IReadOnlyList<HfsqlColumnMetadata>>
|
|
{
|
|
["Client"] =
|
|
[
|
|
new("Client", "cl_pkid"),
|
|
new("Client", "cl_cletype"),
|
|
new("Client", "guid")
|
|
],
|
|
["Role"] = [new("Role", "r_pkid")]
|
|
};
|
|
var connection = new FakeConnection(schema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value);
|
|
}
|
|
|
|
// --- Cas 4 : un seul canari Client présent mais Role présent => Omika -----
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_SingleClientCanaryWithRole_ReturnsOmika()
|
|
{
|
|
// Seul CL_CleType présent (GUID absent), mais Role présent => Omika confirmé.
|
|
var schema = new Dictionary<string, IReadOnlyList<HfsqlColumnMetadata>>
|
|
{
|
|
["Client"] =
|
|
[
|
|
new("Client", "CL_PKID"),
|
|
new("Client", "CL_CleType")
|
|
],
|
|
["Role"] = RoleColumns
|
|
};
|
|
var connection = new FakeConnection(schema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value);
|
|
}
|
|
|
|
// --- Cas 5 : un seul canari Client présent sans Role => échec -------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_SingleClientCanaryWithoutRole_ReturnsFailure()
|
|
{
|
|
// Seul CL_CleType présent (GUID absent), Role absent => incohérent.
|
|
var schema = new Dictionary<string, IReadOnlyList<HfsqlColumnMetadata>>
|
|
{
|
|
["Client"] =
|
|
[
|
|
new("Client", "CL_PKID"),
|
|
new("Client", "CL_CleType")
|
|
]
|
|
};
|
|
var connection = new FakeConnection(schema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("incohérente", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// --- Cas 6 : table fondamentale absente => échec --------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_MissingCommonColumn_ReturnsFailure()
|
|
{
|
|
var schema = new Dictionary<string, IReadOnlyList<HfsqlColumnMetadata>>
|
|
{
|
|
["Client"] = [new("Client", "CL_Nom")]
|
|
};
|
|
var connection = new FakeConnection(schema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("pas reconnue", result.Errors[0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// --- Cas 7 : erreur de requête sur un canari => comportement contrôlé -----
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_QueryError_ReturnsFailureNotBatik()
|
|
{
|
|
var connection = new FakeConnection(throwOnQuery: true);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
// Une erreur de requête ne doit jamais être interprétée comme Batik :
|
|
// le détecteur doit retourner un échec explicite, quel que soit le canari
|
|
// qui a échoué (socle commun ou canari Omika).
|
|
Assert.True(result.IsFailure);
|
|
Assert.False(result.IsSuccess);
|
|
|
|
}
|
|
|
|
|
|
// --- Cas 8 : connexion fermée => ouverture avant détection ---------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_ClosedConnection_OpensBeforeDetection()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
await detector.DetectAsync();
|
|
|
|
Assert.Equal(1, connection.OpenCount);
|
|
Assert.Equal(1, connection.CloseCount);
|
|
}
|
|
|
|
// --- Cas 9 : connexion déjà ouverte => pas de double ouverture -----------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_AlreadyOpenConnection_DoesNotReopen()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema, initialState: HfsqlConnectionState.Open);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
await detector.DetectAsync();
|
|
|
|
Assert.Equal(0, connection.OpenCount);
|
|
Assert.Equal(0, connection.CloseCount);
|
|
}
|
|
|
|
// --- Cas 10 : cancellation demandée avant détection ----------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_CancellationRequested_Throws()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => detector.DetectAsync(cts.Token));
|
|
}
|
|
|
|
// --- Cas 11 : aucune écriture SQL exécutée --------------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_DoesNotExecuteAnyWriteSql()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
await detector.DetectAsync();
|
|
|
|
Assert.Equal(0, connection.ExecuteNonQueryCount);
|
|
Assert.Equal(0, connection.ExecuteScalarCount);
|
|
}
|
|
|
|
// --- Cas 12 : aucune donnée métier lue (requêtes TOP 0 uniquement) --------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_DoesNotReadBusinessData()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
await detector.DetectAsync();
|
|
|
|
Assert.True(connection.QueryCount > 0);
|
|
Assert.All(connection.ExecutedQueries, sql =>
|
|
Assert.True(Regex.IsMatch(sql, @"^SELECT\s+TOP\s+0\s+", RegexOptions.IgnoreCase),
|
|
$"Requête non structurelle exécutée : {sql}"));
|
|
|
|
}
|
|
|
|
// --- Cas 13 : aucun appel à ApplicationRuntimeContext.SetDetectedVersion ---
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_DoesNotTouchApplicationRuntimeContext()
|
|
{
|
|
ApplicationRuntimeContext.Reset();
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
await detector.DetectAsync();
|
|
|
|
Assert.False(ApplicationRuntimeContext.IsConfigured);
|
|
}
|
|
|
|
// --- Cas 14 : résultat déterministe ---------------------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_IsDeterministic()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var first = await detector.DetectAsync();
|
|
var second = await detector.DetectAsync();
|
|
|
|
Assert.Equal(first.IsSuccess, second.IsSuccess);
|
|
Assert.Equal(first.Value, second.Value);
|
|
}
|
|
|
|
// --- Cas 15 : IDatabaseSchemaDetector enregistré en Scoped ----------------
|
|
|
|
[Fact]
|
|
public void AddOmikaData_RegistersDetectorAsScoped()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddOmikaData();
|
|
|
|
var descriptor = services.Single(d => d.ServiceType == typeof(IDatabaseSchemaDetector));
|
|
|
|
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
|
Assert.Equal(typeof(DatabaseSchemaDetector), descriptor.ImplementationType);
|
|
}
|
|
|
|
// --- Cas 16 : aucune classe ODBC exposée dans la signature publique --------
|
|
|
|
[Fact]
|
|
public void DetectorPublicSurface_DoesNotExposeOdbcTypes()
|
|
{
|
|
var detectorType = typeof(DatabaseSchemaDetector);
|
|
var contractType = typeof(IDatabaseSchemaDetector);
|
|
|
|
var publicTypes = new[] { detectorType, contractType };
|
|
|
|
foreach (var type in publicTypes)
|
|
{
|
|
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
|
|
.Select(m => m.DeclaringType)
|
|
.Where(t => t is not null)
|
|
.Cast<Type>();
|
|
|
|
var parameters = type.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
|
.SelectMany(m => m.GetParameters())
|
|
.Select(p => p.ParameterType);
|
|
|
|
var returnTypes = type.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
|
.Select(m => m.ReturnType);
|
|
|
|
var referenced = members.Concat(parameters).Concat(returnTypes);
|
|
|
|
Assert.DoesNotContain(referenced, t =>
|
|
t.Namespace is not null && t.Namespace.StartsWith("System.Data.Odbc", StringComparison.Ordinal));
|
|
}
|
|
}
|
|
|
|
// --- Cas 17 : aucune dépendance au nom de base ----------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_DoesNotDependOnDatabaseName()
|
|
{
|
|
// Le nom de base est totalement différent de BATIK_TEST / OMIKA_TEST.
|
|
// La détection repose uniquement sur la structure (colonnes canaris).
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
var result = await detector.DetectAsync();
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value);
|
|
}
|
|
|
|
// --- Cas 18 : aucune dépendance OpenSchema --------------------------------
|
|
|
|
[Fact]
|
|
public async Task DetectAsync_DoesNotUseOpenSchema()
|
|
{
|
|
var connection = new FakeConnection(OmikaSchema);
|
|
var detector = new DatabaseSchemaDetector(connection);
|
|
|
|
await detector.DetectAsync();
|
|
|
|
Assert.Equal(0, connection.GetColumnsCount);
|
|
}
|
|
|
|
// --- Fake de connexion -----------------------------------------------------
|
|
|
|
private sealed class FakeConnection : IHfsqlConnection
|
|
{
|
|
private readonly IReadOnlyDictionary<string, IReadOnlyList<HfsqlColumnMetadata>>? _schema;
|
|
private readonly bool _throwOnQuery;
|
|
|
|
public FakeConnection(
|
|
IReadOnlyDictionary<string, IReadOnlyList<HfsqlColumnMetadata>>? schema = null,
|
|
bool throwOnQuery = false,
|
|
HfsqlConnectionState initialState = HfsqlConnectionState.Closed)
|
|
{
|
|
_schema = schema;
|
|
_throwOnQuery = throwOnQuery;
|
|
State = initialState;
|
|
}
|
|
|
|
public HfsqlOptions Options => new();
|
|
public HfsqlConnectionState State { get; private set; }
|
|
|
|
public int OpenCount { get; private set; }
|
|
public int CloseCount { get; private set; }
|
|
public int ExecuteNonQueryCount { get; private set; }
|
|
public int ExecuteScalarCount { get; private set; }
|
|
public int QueryCount { get; private set; }
|
|
public int GetColumnsCount { get; private set; }
|
|
public List<string> ExecutedQueries { get; } = new();
|
|
|
|
public Task OpenAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
OpenCount++;
|
|
State = HfsqlConnectionState.Open;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task CloseAsync()
|
|
{
|
|
CloseCount++;
|
|
State = HfsqlConnectionState.Closed;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
State = HfsqlConnectionState.Closed;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public Task<object?> ExecuteScalarAsync(
|
|
string sql,
|
|
IReadOnlyList<object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ExecuteScalarCount++;
|
|
return Task.FromResult<object?>(null);
|
|
}
|
|
|
|
public Task<int> ExecuteNonQueryAsync(
|
|
string sql,
|
|
IReadOnlyList<object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ExecuteNonQueryCount++;
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
|
string sql,
|
|
IReadOnlyList<object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
QueryCount++;
|
|
ExecutedQueries.Add(sql);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (_throwOnQuery)
|
|
throw new InvalidOperationException("Échec contrôlé de la requête.");
|
|
|
|
// Parse "SELECT TOP 0 <col> FROM <table>".
|
|
var match = Regex.Match(sql, @"SELECT\s+TOP\s+0\s+(\w+)\s+FROM\s+(\w+)", RegexOptions.IgnoreCase);
|
|
if (!match.Success)
|
|
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
|
|
|
var column = match.Groups[1].Value;
|
|
var table = match.Groups[2].Value;
|
|
|
|
if (_schema is null || !_schema.TryGetValue(table, out var columns))
|
|
throw new InvalidOperationException($"Table absente : {table}");
|
|
|
|
if (!columns.Any(c => c.ColumnName.Equals(column, StringComparison.OrdinalIgnoreCase)))
|
|
throw new InvalidOperationException($"Colonne absente : {column}");
|
|
|
|
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
|
|
}
|
|
|
|
|
|
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
|
|
string tableName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
GetColumnsCount++;
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (_schema is null || !_schema.TryGetValue(tableName, out var columns))
|
|
return Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
|
|
|
|
return Task.FromResult(columns);
|
|
}
|
|
}
|
|
}
|