Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,98 @@
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class E2EOptionsTests
{
[Fact]
public void AllowHfSqlWrites_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.AllowHfSqlWrites);
}
[Fact]
public void ServiceDatabaseConfirmedTest_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.ServiceDatabaseConfirmedTest);
}
[Fact]
public void WebServiceDatabaseConfirmedTest_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.WebServiceDatabaseConfirmedTest);
}
[Fact]
public void IsWriteAllowed_AllFlagsFalse_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyAllowWrites_IsFalse()
{
var options = new E2EOptions { AllowHfSqlWrites = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyServiceConfirmed_IsFalse()
{
var options = new E2EOptions { ServiceDatabaseConfirmedTest = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyWebServiceConfirmed_IsFalse()
{
var options = new E2EOptions { WebServiceDatabaseConfirmedTest = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_AllFlagsTrue_IsTrue()
{
var options = new E2EOptions
{
AllowHfSqlWrites = true,
ServiceDatabaseConfirmedTest = true,
WebServiceDatabaseConfirmedTest = true
};
Assert.True(options.IsWriteAllowed);
}
[Fact]
public void SectionName_IsE2E()
{
Assert.Equal("E2E", E2EOptions.SectionName);
}
[Fact]
public void ToString_DoesNotExposeSecrets()
{
var options = new E2EOptions
{
AllowHfSqlWrites = true,
ServiceDatabaseConfirmedTest = true,
WebServiceDatabaseConfirmedTest = true
};
var text = options.ToString();
Assert.Contains("AllowHfSqlWrites = True", text);
Assert.Contains("ServiceDatabaseConfirmedTest = True", text);
Assert.Contains("WebServiceDatabaseConfirmedTest = True", text);
}
}
@@ -0,0 +1,452 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class HfSqlFileAccessResolverTests
{
private static HfSqlConnectionOptions CreateOptions() => new()
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret",
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = "PLUS_PASSWORD"
}
};
[Fact]
public async Task ResolveAsync_DirectPass_ReturnsDirect()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Direct, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedPass_ReturnsProtectedMode()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.ProtectionStd, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedPlusPass_ReturnsProtectionPlus()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: ["ParametreSysteme"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreSysteme", ExpectedGroup = "ProtectionPlus" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.ProtectionPlus, result["ParametreSysteme"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedFail_ReturnsFailed()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Failed, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_NoExpectedGroup_DirectFail_ReturnsFailed()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = null }
};
var result = await resolver.ResolveAsync(files);
// Sans groupe attendu, pas de tentative protégée : le fichier reste Failed.
Assert.Equal(HfSqlFileAccessMode.Failed, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_GetProfile_ReturnsCachedProfile()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
var profile = resolver.GetProfile("Client");
Assert.NotNull(profile);
Assert.Equal(HfSqlFileAccessMode.Direct, profile!.ObservedAccess);
}
[Fact]
public async Task ResolveAsync_GetProfile_UnknownFile_ReturnsNull()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
Assert.Null(resolver.GetProfile("Inexistant"));
}
[Fact]
public async Task ResolveAsync_InvalidIdentifier_ReturnsFailed()
{
var factory = new FakeFactory(directPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Bad;Name", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Failed, result["Bad;Name"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_ExplicitStdPassword_UsedInExtendedProperties()
{
// Vérifie que le resolver utilise la valeur EXACTE de FilePasswords.ProtectionStd
// dans les Extended Properties (aucune dérivation).
var factory = new CapturingFactory(directPass: [], protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
Assert.NotNull(factory.LastExtendedProperties);
Assert.Contains("Password=Client:STD_PASSWORD", factory.LastExtendedProperties);
Assert.DoesNotContain("DeriveProtectionStd", factory.LastExtendedProperties);
}
[Fact]
public async Task ResolveAsync_ExplicitPlusPassword_UsedInExtendedProperties()
{
// Vérifie que le resolver utilise la valeur EXACTE de FilePasswords.ProtectionPlus
// dans les Extended Properties (aucune dérivation).
var factory = new CapturingFactory(directPass: [], protectedPass: ["ParametreSysteme"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreSysteme", ExpectedGroup = "ProtectionPlus" }
};
await resolver.ResolveAsync(files);
Assert.NotNull(factory.LastExtendedProperties);
Assert.Contains("Password=ParametreSysteme:PLUS_PASSWORD", factory.LastExtendedProperties);
Assert.DoesNotContain("DeriveProtectionPlus", factory.LastExtendedProperties);
}
[Fact]
public async Task ResolveAsync_NoPasswordsConfigured_DirectOnly()
{
// Sans passwords configurés, le resolver ne tente aucune connexion protégée.
var options = new HfSqlConnectionOptions
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret",
FilePasswords = new HfSqlFilePasswordOptions() // aucun password
};
var factory = new CapturingFactory(directPass: ["Client"], protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
// Direct PASS : pas de connexion protégée nécessaire.
Assert.Equal(HfSqlFileAccessMode.Direct, result["Client"].ObservedAccess);
Assert.Null(factory.LastExtendedProperties);
}
[Fact]
public void ToString_DoesNotExposeSecrets()
{
var options = CreateOptions();
var str = options.ToString();
Assert.DoesNotContain("STD_PASSWORD", str);
Assert.DoesNotContain("PLUS_PASSWORD", str);
Assert.DoesNotContain("secret", str);
}
/// <summary>
/// Fabrique simulée qui capture les Extended Properties pour vérifier que
/// les mots de passe exacts des options sont utilisés (aucune dérivation).
/// </summary>
private sealed class CapturingFactory : IHfSqlOleDbConnectionFactory
{
private readonly HashSet<string> _directPass;
private readonly HashSet<string> _protectedPass;
public string? LastExtendedProperties { get; private set; }
public CapturingFactory(string[] directPass, string[]? protectedPass = null)
{
_directPass = new HashSet<string>(directPass, StringComparer.OrdinalIgnoreCase);
_protectedPass = new HashSet<string>(protectedPass ?? [], StringComparer.OrdinalIgnoreCase);
}
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
if (!string.IsNullOrWhiteSpace(extendedProperties))
{
LastExtendedProperties = extendedProperties;
}
bool isProtected = !string.IsNullOrWhiteSpace(extendedProperties);
return new FakeConnection(
isProtected ? _protectedPass : _directPass,
isProtected ? extendedProperties : null);
}
}
/// <summary>
/// Fabrique simulée : contrôle quels fichiers passent en Direct et lesquels
/// passent en protégé. Vérifie aussi que les mots de passe sont bien fournis
/// dans les Extended Properties de la connexion protégée.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
private readonly HashSet<string> _directPass;
private readonly HashSet<string> _protectedPass;
public FakeFactory(string[] directPass, string[]? protectedPass = null)
{
_directPass = new HashSet<string>(directPass, StringComparer.OrdinalIgnoreCase);
_protectedPass = new HashSet<string>(protectedPass ?? [], StringComparer.OrdinalIgnoreCase);
}
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
bool isProtected = !string.IsNullOrWhiteSpace(extendedProperties);
return new FakeConnection(
isProtected ? _protectedPass : _directPass,
isProtected ? extendedProperties : null);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly HashSet<string> _passingTables;
private readonly string? _extendedProperties;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(HashSet<string> passingTables, string? extendedProperties)
{
_passingTables = passingTables;
_extendedProperties = extendedProperties;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "Omika";
public override string DataSource => "hfsql-serveur:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => _state = ConnectionState.Open;
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand(_passingTables);
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
private readonly HashSet<string> _passingTables;
public FakeCommand(HashSet<string> passingTables)
{
_passingTables = passingTables;
}
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => null;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
// Extraire le nom de table depuis "SELECT TOP 1 * FROM <Table>".
var table = CommandText.Split("FROM ", StringSplitOptions.RemoveEmptyEntries)[^1].Trim();
if (_passingTables.Contains(table))
{
return new FakeReader();
}
throw new InvalidOperationException("Code erreur : 70114");
}
}
private sealed class FakeReader : DbDataReader
{
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => throw new NotSupportedException();
public override object this[string name] => throw new NotSupportedException();
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => throw new NotSupportedException();
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => throw new NotSupportedException();
public override int GetOrdinal(string name) => throw new NotSupportedException();
public override string GetString(int ordinal) => throw new NotSupportedException();
public override object GetValue(int ordinal) => throw new NotSupportedException();
public override int GetValues(object[] values) => throw new NotSupportedException();
public override bool IsDBNull(int ordinal) => throw new NotSupportedException();
public override bool NextResult() => false;
public override bool Read() => true;
public override IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,312 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Organisation;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Collaborateur :
/// CREATE → UPDATE → ARCHIVE (désactivation), le tout dans UNE transaction
/// externe unique, puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbCollaborateurLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Collaborateur, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Collaborateur Batik.
var logicalFiles = new[]
{
"Collaborateur", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikCollaborateurRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new CollaborateurCreateCommand
{
Nom = "INTEGRATION_CYCLE_BATIK",
Prenom = "Cycle",
Code = "ICB",
Actif = true
};
Collaborateur created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Nom);
Assert.NotNull(created.SourceReference);
Assert.NotNull(created.SourceReference!.NumericId);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new CollaborateurUpdateCommand
{
Nom = "INTEGRATION_CYCLE_BATIK_UPDATED",
Actif = true
};
Collaborateur? updated = await repository.UpdateAsync(
created.SourceReference!.NumericId!.Value.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Nom);
// 7. ARCHIVE (désactivation) dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.SourceReference!.NumericId!.Value.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Collaborateur Omika.
var logicalFiles = new[]
{
"Collaborateur", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaCollaborateurRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new CollaborateurCreateCommand
{
Nom = "INTEGRATION_CYCLE_OMIKA",
Prenom = "Cycle",
Code = "ICO",
Actif = true
};
Collaborateur created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Nom);
Assert.NotNull(created.SourceReference);
Assert.NotNull(created.SourceReference!.EntityGuid);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new CollaborateurUpdateCommand
{
Nom = "INTEGRATION_CYCLE_OMIKA_UPDATED",
Actif = true
};
Collaborateur? updated = await repository.UpdateAsync(
created.SourceReference!.EntityGuid!.Value.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Nom);
// 7. ARCHIVE (désactivation) dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.SourceReference!.EntityGuid!.Value.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,104 @@
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class HfSqlOleDbConnectionStringBuilderTests
{
private static HfSqlConnectionOptions CreateOptions() => new()
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret"
};
[Fact]
public void Build_ContainsProvider()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("PCSOFT.HFSQL", connectionString);
}
[Fact]
public void Build_ContainsServerAndPort()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("hfsql-serveur:4900", connectionString);
}
[Fact]
public void Build_ContainsDatabase()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("Omika", connectionString);
}
[Fact]
public void Build_ContainsUser()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("tech", connectionString);
}
[Fact]
public void Build_ContainsCredential()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("secret", connectionString);
}
[Fact]
public void Build_OmitsExtendedPropertiesWhenNull()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.DoesNotContain("Extended Properties", connectionString);
}
[Fact]
public void Build_IncludesExtendedPropertiesWhenProvided()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(
CreateOptions(),
"Password=Client:abc123");
Assert.Contains("Extended Properties", connectionString);
Assert.Contains("Password=Client:abc123", connectionString);
}
[Fact]
public void Build_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlOleDbConnectionStringBuilder.Build(null!));
}
[Fact]
public void EscapeExtendedPropertyValue_EscapesSpecialCharacters()
{
var escaped = HfSqlOleDbConnectionStringBuilder.EscapeExtendedPropertyValue("a=b;c:d\\e");
Assert.Equal("a\\=b\\;c\\:d\\\\e", escaped);
}
[Fact]
public void BuildFilePasswordEntry_DoesNotEscapeStructuralColon()
{
var entry = HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry("Client", "abc123");
Assert.Equal("Password=Client:abc123", entry);
}
[Fact]
public void BuildFilePasswordEntry_EscapesFilenameAndPasswordIndividually()
{
var entry = HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry("a=b", "c;d");
Assert.Equal("Password=a\\=b:c\\;d", entry);
}
}
@@ -0,0 +1,278 @@
using System.Data.Common;
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Clients;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du chemin de création (CreateAsync).
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée une transaction externe, on exécute <c>CreateAsync</c> dans cette
/// transaction, on vérifie le client créé, puis on annule (rollback) pour ne
/// laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur le client
/// canonique retourné.
/// </summary>
public class HfSqlOleDbCreateIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au chemin Create, en fonction du mode d'accès observé
/// par le résolveur. Les fichiers accessibles directement n'ont pas besoin
/// d'entrée.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task CreateAsync_Batik_PersistsAndRollsBack()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au chemin Create Batik.
var logicalFiles = new[] { "Client", "ParametreMetier", "Adresse", "Client_Adresse" };
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. Transaction externe (rollback à la fin).
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikClientRepository(connection);
// 5. Création dans la transaction externe.
var command = new ClientCreateCommand
{
Nom = "INTEGRATION_CREATE_BATIK",
Code = "INT-CREATE-BATIK",
AdresseLigne1 = "1 rue du Test",
AdresseCodePostal = "75000",
AdresseVille = "Paris"
};
Client client = await repository.CreateAsync(command, transaction, CancellationToken.None);
// 6. Vérification du client canonique.
Assert.NotNull(client);
Assert.Equal("INTEGRATION_CREATE_BATIK", client.Nom);
Assert.NotEqual(Guid.Empty, client.Id);
// 7. Rollback : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task CreateAsync_Omika_PersistsAndRollsBack()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au chemin Create Omika.
var logicalFiles = new[] { "Client", "ParametreMetier", "Adresse", "Client_Adresse" };
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. Transaction externe (rollback à la fin).
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaClientRepository(connection);
// 5. Création dans la transaction externe.
var command = new ClientCreateCommand
{
Nom = "INTEGRATION_CREATE_OMIKA",
Code = "INT-CREATE-OMIKA",
AdresseLigne1 = "1 rue du Test",
AdresseCodePostal = "75000",
AdresseVille = "Paris"
};
Client client = await repository.CreateAsync(command, transaction, CancellationToken.None);
// 6. Vérification du client canonique.
Assert.NotNull(client);
Assert.Equal("INTEGRATION_CREATE_OMIKA", client.Nom);
Assert.NotEqual(Guid.Empty, client.Id);
// 7. Rollback : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,306 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Dossier :
/// CREATE → UPDATE → ARCHIVE, le tout dans UNE transaction externe unique,
/// puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbDossierLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Dossier, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Dossier Batik.
var logicalFiles = new[]
{
"Dossier", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikDossierRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new DossierCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
ReferenceMetier = "DOS-CYCLE-BATIK"
};
Dossier created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new DossierUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED",
ReferenceMetier = "DOS-CYCLE-BATIK-UPD"
};
Dossier? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Dossier Omika.
var logicalFiles = new[]
{
"Dossier", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaDossierRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new DossierCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
ReferenceMetier = "DOS-CYCLE-OMIKA"
};
Dossier created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new DossierUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED",
ReferenceMetier = "DOS-CYCLE-OMIKA-UPD"
};
Dossier? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using Omika.Core.Compatibility;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.SchemaDetection;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// modes d'accès observés.
/// </summary>
public class HfSqlOleDbIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
[Fact]
public async Task ResolveAsync_Batik_Client_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Client"].IsAccessible,
$"Client Batik doit être accessible (mode observé : {result["Client"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task ResolveAsync_Omika_Client_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Client"].IsAccessible,
$"Client Omika doit être accessible (mode observé : {result["Client"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
// --- Détection de schéma réelle (BATIK_TEST / OMIKA_TEST) -----------------
[Fact]
public async Task DetectAsync_BatikTest_ReturnsBatik()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
var detector = new DatabaseSchemaDetector(connection);
try
{
var result = await detector.DetectAsync();
Assert.True(result.IsSuccess,
$"La détection Batik a échoué : {string.Join("; ", result.Errors)}");
Assert.Equal(DatabaseSchemaVersion.Batik, result.Value);
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task DetectAsync_OmikaTest_ReturnsOmika()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
var detector = new DatabaseSchemaDetector(connection);
try
{
var result = await detector.DetectAsync();
Assert.True(result.IsSuccess,
$"La détection Omika a échoué : {string.Join("; ", result.Errors)}");
Assert.Equal(DatabaseSchemaVersion.Omika, result.Value);
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
// Le provider OLE DB peut lever des exceptions natives au teardown.
// On ignore les erreurs de connexion (serveur injoignable) mais on
// propage les erreurs de logique.
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,231 @@
using System.Text.Json;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels pour les tables Intervention.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// modes d'accès observés.
/// </summary>
public class HfSqlOleDbInterventionIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
[Fact]
public async Task ResolveAsync_Batik_Intervention_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Intervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Intervention"].IsAccessible,
$"Intervention Batik doit être accessible (mode observé : {result["Intervention"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task ResolveAsync_Omika_Intervention_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Intervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["Intervention"].IsAccessible,
$"Intervention Omika doit être accessible (mode observé : {result["Intervention"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
[Fact]
public async Task ResolveAsync_Batik_EtatIntervention_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "EtatIntervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["EtatIntervention"].IsAccessible,
$"EtatIntervention Batik doit être accessible (mode observé : {result["EtatIntervention"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
[Fact(Skip = "ParametreMetier n'existe pas dans la base Batik de test (HFSQL 70114 : fichier non décrit) ; ce fichier n'existe qu'en schéma Omika. La cible du test est à revoir avant réactivation.")]
public async Task ResolveAsync_Batik_ParametreMetier_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreMetier", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["ParametreMetier"].IsAccessible,
$"ParametreMetier Batik doit être accessible (mode observé : {result["ParametreMetier"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
[Fact]
public async Task ResolveAsync_Omika_ParametreMetier_IsAccessible()
{
var config = LoadConfig();
if (config is null)
return;
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreMetier", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Assert.True(result["ParametreMetier"].IsAccessible,
$"ParametreMetier Omika doit être accessible (mode observé : {result["ParametreMetier"].ObservedAccess})");
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return;
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,317 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Intervention :
/// CREATE → UPDATE → ARCHIVE, le tout dans UNE transaction externe unique,
/// puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbInterventionLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Intervention, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Intervention Batik.
var logicalFiles = new[]
{
"Intervention", "ParametreMetier", "Intervention_Collaborateur",
"Intervention_Personne", "EtatIntervention"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikInterventionRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new InterventionCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
Reference = "INT-CYCLE-BATIK",
Description = "Cycle de vie complet",
CollaborateurIds = ["1"],
ContactIds = ["1"]
};
Intervention created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new InterventionUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED",
Reference = "INT-CYCLE-BATIK-UPD",
Description = "Cycle de vie mis à jour",
CollaborateurIds = ["2"],
ContactIds = ["2"]
};
Intervention? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Intervention Omika.
var logicalFiles = new[]
{
"Intervention", "ParametreMetier", "Intervention_Personne", "EtatIntervention"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaInterventionRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new InterventionCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
Reference = "INT-CYCLE-OMIKA",
Description = "Cycle de vie complet",
ContactIds = ["1"]
};
Intervention created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new InterventionUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED",
Reference = "INT-CYCLE-OMIKA-UPD",
Description = "Cycle de vie mis à jour",
ContactIds = ["2"]
};
Intervention? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,332 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Planning;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie RendezVous :
/// CREATE → UPDATE → CANCEL → ARCHIVE, le tout dans UNE transaction externe
/// unique, puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Cancel puis
/// Archive dans cette même transaction, on vérifie chaque étape, puis on annule
/// (rollback) pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbRendezVousLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie RendezVous, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateCancelArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie RendezVous Batik.
var logicalFiles = new[]
{
"Planning_RDV", "Planning_RDV_Personne", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikRendezVousRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new RendezVousCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
Description = "Cycle de vie Batik",
Debut = new DateTime(2026, 2, 1, 9, 0, 0),
Fin = new DateTime(2026, 2, 1, 10, 0, 0)
};
RendezVous created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new RendezVousUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED"
};
RendezVous? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. CANCEL dans la MÊME transaction.
var annulation = new AnnulationRendezVousCommand
{
DateAnnulation = DateTime.Now,
OrigineKey = null,
MotifKey = null
};
bool cancelled = await repository.CancelAsync(
created.Id.ToString(), annulation, transaction, CancellationToken.None);
Assert.True(cancelled);
// 8. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 9. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateCancelArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie RendezVous Omika.
var logicalFiles = new[]
{
"RDV_Planning", "RDV_Collaborateur", "RDV_Contact", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaRendezVousRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new RendezVousCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
Description = "Cycle de vie Omika",
Debut = new DateTime(2026, 2, 1, 9, 0, 0),
Fin = new DateTime(2026, 2, 1, 10, 0, 0)
};
RendezVous created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new RendezVousUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED"
};
RendezVous? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. CANCEL dans la MÊME transaction.
var annulation = new AnnulationRendezVousCommand
{
DateAnnulation = DateTime.Now,
OrigineKey = null,
MotifKey = null
};
bool cancelled = await repository.CancelAsync(
created.Id.ToString(), annulation, transaction, CancellationToken.None);
Assert.True(cancelled);
// 8. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 9. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,109 @@
using Microsoft.Extensions.Configuration;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class HfSqlTestConfigurationLoaderTests
{
[Theory]
[InlineData("Development")]
[InlineData("development")]
[InlineData("Test")]
[InlineData("test")]
public void IsTestEnvironment_TestEnvironments_ReturnsTrue(string environment)
{
Assert.True(HfSqlTestConfigurationLoader.IsTestEnvironment(environment));
}
[Theory]
[InlineData("Production")]
[InlineData("Staging")]
[InlineData(null)]
[InlineData("")]
public void IsTestEnvironment_NonTestEnvironments_ReturnsFalse(string? environment)
{
Assert.False(HfSqlTestConfigurationLoader.IsTestEnvironment(environment));
}
[Fact]
public void ResolveConfigPath_ExplicitEnvironmentVariable_ReturnsFullPath()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"hfsql-test-{Guid.NewGuid():N}.json");
try
{
File.WriteAllText(tempFile, "{}");
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, tempFile);
var path = HfSqlTestConfigurationLoader.ResolveConfigPath();
Assert.NotNull(path);
Assert.Equal(Path.GetFullPath(tempFile), path);
}
finally
{
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, null);
File.Delete(tempFile);
}
}
[Fact]
public void AddLocalTestConfiguration_ProductionEnvironment_DoesNotLoad()
{
var configuration = new ConfigurationManager();
var loaded = HfSqlTestConfigurationLoader.AddLocalTestConfiguration(
configuration, "Production");
Assert.False(loaded);
}
[Fact]
public void AddLocalTestConfiguration_DevelopmentWithExplicitPath_LoadsFile()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"hfsql-test-{Guid.NewGuid():N}.json");
try
{
File.WriteAllText(tempFile, """{ "E2E": { "AllowHfSqlWrites": false } }""");
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, tempFile);
var configuration = new ConfigurationManager();
var loaded = HfSqlTestConfigurationLoader.AddLocalTestConfiguration(
configuration, "Development");
Assert.True(loaded);
Assert.Equal("False", configuration["E2E:AllowHfSqlWrites"]);
}
finally
{
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, null);
File.Delete(tempFile);
}
}
[Fact]
public void AddLocalTestConfiguration_DevelopmentWithoutFile_DoesNotLoad()
{
// Pointe vers un fichier inexistant pour garantir l'absence de chargement.
var missingPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json");
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, missingPath);
try
{
var configuration = new ConfigurationManager();
var loaded = HfSqlTestConfigurationLoader.AddLocalTestConfiguration(
configuration, "Development");
Assert.False(loaded);
}
finally
{
Environment.SetEnvironmentVariable(
HfSqlTestConfigurationLoader.ConfigPathEnvironmentVariable, null);
}
}
}
@@ -0,0 +1,322 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.Connection;
public class HfsqlConnectionTests
{
private static HfsqlOptions CreateOptions() => new()
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika"
};
[Fact]
public void InitialState_IsClosed()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task OpenAsync_Success_SetsOpen()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Failure_SetsFaulted()
{
var factory = new FakeFactory(throwOnOpen: true);
var connection = new HfsqlConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(() => connection.OpenAsync());
Assert.Equal(HfsqlConnectionState.Faulted, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotCreateSecondConnection()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.CreatedCount);
}
[Fact]
public async Task CloseAsync_SetsClosed()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task CloseAsync_Twice_DoesNotThrow()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await connection.CloseAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task DisposeAsync_ClosesConnection()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await connection.DisposeAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task ExecuteScalarAsync_WhenClosed_Throws()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.ExecuteScalarAsync("SELECT 1"));
}
[Fact]
public async Task ExecuteScalarAsync_WhenOpen_ReturnsValue()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var result = await connection.ExecuteScalarAsync("SELECT 1");
Assert.Equal(42, result);
}
[Fact]
public async Task ExecuteNonQueryAsync_WhenOpen_ReturnsAffectedRows()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var result = await connection.ExecuteNonQueryAsync("UPDATE T SET X = 1");
Assert.Equal(3, result);
}
[Fact]
public async Task QueryAsync_WhenOpen_ReturnsRows()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM T");
Assert.Single(rows);
Assert.Equal("valeur", rows[0]["Col"]);
}
[Fact]
public async Task ExecuteScalarAsync_EmptySql_Throws()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await connection.OpenAsync();
await Assert.ThrowsAsync<ArgumentException>(
() => connection.ExecuteScalarAsync(" "));
}
[Fact]
public async Task GetColumnsAsync_WhenOpen_ReturnsColumns()
{
var factory = new FakeFactory();
var connection = new HfsqlConnection(CreateOptions(), factory);
await connection.OpenAsync();
var columns = await connection.GetColumnsAsync("Lieu");
Assert.Equal(2, columns.Count);
Assert.Contains(columns, c => c.ColumnName == "LI_PKID");
Assert.Contains(columns, c => c.ColumnName == "LI_CleType");
}
[Fact]
public async Task GetColumnsAsync_WhenClosed_Throws()
{
var connection = new HfsqlConnection(CreateOptions(), new FakeFactory());
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.GetColumnsAsync("Lieu"));
}
private sealed class FakeFactory : IHfsqlConnectionFactory
{
private readonly bool _throwOnOpen;
public FakeFactory(bool throwOnOpen = false)
{
_throwOnOpen = throwOnOpen;
}
public int CreatedCount { get; private set; }
public DbConnection Create(HfsqlOptions options)
{
CreatedCount++;
return new FakeDbConnection(_throwOnOpen);
}
}
private sealed class FakeDbConnection : DbConnection
{
private readonly bool _throwOnOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeDbConnection(bool throwOnOpen)
{
_throwOnOpen = throwOnOpen;
}
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "Omika";
public override string DataSource => "fake";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
if (_throwOnOpen)
throw new InvalidOperationException("Échec contrôlé de l'ouverture.");
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeDbCommand(this);
public override DataTable GetSchema(string collectionName, string[] restrictionValues)
{
var table = new DataTable();
table.Columns.Add("COLUMN_NAME", typeof(string));
table.Rows.Add("LI_PKID");
table.Rows.Add("LI_CleType");
return table;
}
}
private sealed class FakeDbCommand : DbCommand
{
private readonly FakeDbConnection _connection;
public FakeDbCommand(FakeDbConnection connection)
{
_connection = connection;
}
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = null;
protected override DbParameterCollection DbParameterCollection { get; } = null!;
protected override DbTransaction? DbTransaction { get; set; } = null;
public override void Cancel() { }
public override int ExecuteNonQuery() => 3;
public override object? ExecuteScalar() => 42;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => throw new NotSupportedException();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
=> new FakeDbDataReader();
}
private sealed class FakeDbDataReader : DbDataReader
{
private int _readCount;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => "valeur";
public override object this[string name] => "valeur";
public override bool Read() => _readCount++ == 0;
public override bool NextResult() => false;
public override IEnumerator GetEnumerator() => throw new NotSupportedException();
public override int Depth => 0;
public override string GetName(int ordinal) => "Col";
public override int GetOrdinal(string name) => 0;
public override string GetDataTypeName(int ordinal) => "string";
public override Type GetFieldType(int ordinal) => typeof(string);
public override object GetValue(int ordinal) => "valeur";
public override int GetValues(object[] values)
{
values[0] = "valeur";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetString(int ordinal) => throw new NotSupportedException();
}
}
@@ -0,0 +1,85 @@
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.Connection;
public class HfsqlOdbcConnectionStringBuilderTests
{
private static HfsqlOptions CreateOptions() => new()
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika",
Login = "tech",
Password = "secret"
};
[Fact]
public void Build_ContainsDriver()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("HFSQL Client/Server", connectionString);
}
[Fact]
public void Build_ContainsServer()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("hfsql-serveur", connectionString);
}
[Fact]
public void Build_ContainsPort()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("4900", connectionString);
}
[Fact]
public void Build_ContainsDatabase()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("Omika", connectionString);
}
[Fact]
public void Build_ContainsLogin()
{
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("tech", connectionString);
}
[Fact]
public void Build_ContainsPassword()
{
// Le mot de passe est transmis au provider via la chaîne de connexion.
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("secret", connectionString);
}
[Fact]
public void Build_OmitsLoginAndPasswordWhenAbsent()
{
var options = CreateOptions();
options.Login = null;
options.Password = null;
var connectionString = HfsqlOdbcConnectionStringBuilder.Build(options);
Assert.DoesNotContain("UID", connectionString);
Assert.DoesNotContain("PWD", connectionString);
}
[Fact]
public void Build_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfsqlOdbcConnectionStringBuilder.Build(null!));
}
}
@@ -0,0 +1,87 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class HfsqlOptionsValidatorTests
{
private static HfsqlOptions CreateValidOptions() => new()
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika",
ConnectionName = "ConnexionLocale"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = HfsqlOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingDriver_ReturnsFailure()
{
var options = CreateValidOptions();
options.Driver = null!;
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("driver", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidTimeout_ReturnsFailure()
{
var options = CreateValidOptions();
options.ConnectionTimeoutSeconds = 0;
var result = HfsqlOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("délai", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new HfsqlOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<HfsqlOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<HfsqlOptions> validator = new HfsqlOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<HfsqlOptions> validator = new HfsqlOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CreateDefault_ProvidesDriver()
{
var options = HfsqlOptionsValidator.CreateDefault();
Assert.Equal(HfsqlOptions.DefaultOdbcDriver, options.Driver);
}
}
@@ -0,0 +1,114 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class LicenseConnectionOptionsValidatorTests
{
private static LicenseConnectionOptions CreateValidOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_LICENSES",
Login = "susana"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = LicenseConnectionOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = CreateValidOptions();
options.Server = null;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidPort_ReturnsFailure()
{
var options = CreateValidOptions();
options.Port = 0;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabase_ReturnsFailure()
{
var options = CreateValidOptions();
options.DatabaseName = null;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingLogin_ReturnsFailure()
{
var options = CreateValidOptions();
options.Login = null;
var result = LicenseConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("compte", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new LicenseConnectionOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<LicenseConnectionOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<LicenseConnectionOptions> validator = new LicenseConnectionOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<LicenseConnectionOptions> validator = new LicenseConnectionOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ToString_DoesNotExposePassword()
{
var options = CreateValidOptions();
options.Password = "secret";
options.FilePasswords.ProtectionStd = "secret-std";
var text = options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,305 @@
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class LicenseOleDbConnectionTests
{
private static LicenseConnectionOptions CreateOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_LICENSES",
Login = "susana",
Password = "secret",
FilePasswords = new LicenseFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = null
}
};
[Fact]
public void CreateForLicense_MapsAllFields()
{
var options = CreateOptions();
var result = HfSqlConnectionOptionsFactory.CreateForLicense(options);
Assert.Equal("127.0.0.1", result.Server);
Assert.Equal(4900, result.Port);
Assert.Equal("BASE_LICENSES", result.Database);
Assert.Equal("susana", result.User);
Assert.Equal("secret", result.Credential);
Assert.Equal("STD_PASSWORD", result.FilePasswords.ProtectionStd);
Assert.Null(result.FilePasswords.ProtectionPlus);
}
[Fact]
public void CreateForLicense_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlConnectionOptionsFactory.CreateForLicense(null!));
}
[Fact]
public async Task OpenAsync_OpensConnection()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotReopen()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.OpenCount);
}
[Fact]
public async Task CloseAsync_ClosesConnection()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task QueryAsync_ReturnsRows()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM Licence");
Assert.Single(rows);
Assert.Equal("LIC-001", rows[0]["NumeroLicence"]);
}
[Fact]
public async Task ExecuteScalarAsync_ReturnsValue()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var value = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM Licence");
Assert.Equal(1L, value);
}
[Fact]
public async Task QueryAsync_NotOpen_Throws()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.QueryAsync("SELECT * FROM Licence"));
}
[Fact]
public async Task Options_ExposesConnectionName()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
Assert.Equal(LicenseConnectionOptions.ConnectionName, connection.Options.ConnectionName);
Assert.Equal("BASE_LICENSES", connection.Options.DatabaseName);
Assert.Equal("susana", connection.Options.Login);
}
[Fact]
public async Task Options_DoesNotExposePassword()
{
var factory = new FakeFactory();
await using var connection = new LicenseOleDbConnection(CreateOptions(), factory);
var text = connection.Options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("STD_PASSWORD", text, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Fabrique simulée qui retourne une connexion factice sans serveur HFSQL.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
public int OpenCount { get; private set; }
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
return new FakeConnection(() => OpenCount++);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly Action _onOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(Action onOpen)
{
_onOpen = onOpen;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "BASE_LICENSES";
public override string DataSource => "127.0.0.1:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
_onOpen();
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand();
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => 1L;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => new FakeReader();
}
private sealed class FakeReader : DbDataReader
{
private bool _read;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => GetValue(ordinal);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => typeof(string);
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => "NumeroLicence";
public override int GetOrdinal(string name) => name == "NumeroLicence" ? 0 : throw new IndexOutOfRangeException();
public override string GetString(int ordinal) => "LIC-001";
public override object GetValue(int ordinal) => "LIC-001";
public override int GetValues(object[] values)
{
values[0] = "LIC-001";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool NextResult() => false;
public override bool Read()
{
if (_read)
return false;
_read = true;
return true;
}
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,36 @@
using System.Data;
using System.Data.Odbc;
using Omika.Data.Configuration;
using Omika.Data.Connection;
namespace Omika.Tests.Connection;
public class OdbcConnectionFactoryTests
{
[Fact]
public void Create_ReturnsOdbcConnection()
{
var factory = new OdbcConnectionFactory();
var options = new HfsqlOptions
{
Driver = "HFSQL Client/Server",
Server = "hfsql-serveur",
Port = 4900,
DatabaseName = "Omika"
};
var connection = factory.Create(options);
Assert.IsType<OdbcConnection>(connection);
Assert.Equal(ConnectionState.Closed, connection.State);
Assert.Contains("hfsql-serveur", connection.ConnectionString);
}
[Fact]
public void Create_NullOptions_Throws()
{
var factory = new OdbcConnectionFactory();
Assert.Throws<ArgumentNullException>(() => factory.Create(null!));
}
}
@@ -0,0 +1,345 @@
using System.Data.OleDb;
using System.Text;
using System.Text.Json;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
/// <summary>
/// Sonde RUNTIME (lecture seule) : connecte à OMIKA_TEST via OLE DB HFSQL,
/// inspecte le schéma réel des tables utilisées par
/// <see cref="Omika.Data.Repositories.Omika.OmikaRapportChampRepository"/> puis
/// exécute les 8 requêtes de pré-remplissage + les validations de jointures RDV/CR.
/// N'écrit jamais dans la base. Les résultats sont écrits dans un fichier du
/// workspace (artifacts/runtime-rapportchamp.txt) pour analyse.
/// </summary>
public class OmikaRapportChampRuntimeTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string OutputPath = @"D:\Avalonia\Omika\artifacts\runtime-rapportchamp.txt";
[Fact]
public async Task Runtime_Validate_RapportChamp_Queries()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA RAPPORT CHAMP — RUNTIME PROBE (read-only) ===");
var config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
using var connection = (OleDbConnection)factory.Create(options);
try
{
connection.Open();
sb.AppendLine($"CONNECTION: OPEN (DB={config.Omika.Database})");
}
catch (Exception ex)
{
sb.AppendLine($"CONNECTION FAILED: {Redact(ex.Message)}");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
// 1. Schéma réel des tables clés.
string[] keyTables =
[
"Intervention", "Dossier", "Client", "Adresse", "Lieu", "Societe",
"Collaborateur", "ParametreMetier", "RDV_Planning", "RDV_Collaborateur",
"CompteRendu", "Personne", "Intervention_Personne", "Document",
"RubriquesDocument", "MailModele", "ModeleRapport", "ModeleRapportMob"
];
foreach (string table in keyTables)
{
sb.AppendLine();
sb.AppendLine($"── COLUMNS: {table} ──");
try
{
var cols = connection.GetSchema("Columns", [null, null, table, null]);
foreach (System.Data.DataRow row in cols.Rows)
{
string col = row["COLUMN_NAME"]?.ToString() ?? "?";
string type = row["TYPE_NAME"]?.ToString() ?? "?";
sb.AppendLine($" {col,-28} {type}");
}
}
catch (Exception ex)
{
sb.AppendLine($" ERROR: {Redact(ex.Message)}");
}
}
// 2. Découverte d'une intervention réelle.
long interventionId = 0;
sb.AppendLine();
sb.AppendLine("── DISCOVER INTERVENTION ──");
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_IDDossier, IN_IDClient FROM Intervention";
using var reader = cmd.ExecuteReader();
int shown = 0;
while (reader.Read() && shown < 10)
{
long pk = ReadInt64(reader, 0);
if (interventionId == 0) interventionId = pk;
sb.AppendLine($" IN_PKID={pk} RefInterne={Str(reader,1)} Titre={Str(reader,2)} IDDossier={Str(reader,3)} IDClient={Str(reader,4)}");
shown++;
}
sb.AppendLine($" CHOSEN interventionId = {interventionId}");
}
catch (Exception ex)
{
sb.AppendLine($" ERROR: {Redact(ex.Message)}");
}
if (interventionId == 0)
{
sb.AppendLine("NO INTERVENTION FOUND");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
// 3. Exécution des 8 requêtes (SQL identique au repository).
var queries = new (string Title, string Sql)[]
{
("InterventionSql", InterventionSql),
("DossierSql", DossierSql),
("ClientAdressesSql", ClientAdressesSql),
("CompteRenduSql", CompteRenduSql),
("RdvSql", RdvSql),
("ContactsSiteSql", ContactsSiteSql),
("ContactsDonneursSql", ContactsDonneursSql),
("PhotosSql", PhotosSql)
};
foreach (var q in queries)
{
sb.AppendLine();
sb.AppendLine($"── {q.Title} ──");
sb.AppendLine($" SQL: {q.Sql.Replace('\n', ' ').Trim()}");
sb.AppendLine($" PARAMS: {interventionId}");
DumpQuery(connection, sb, q.Sql, interventionId);
}
// 4. Validation jointure RDV collaborateur (RVC_GUID_Colb = Collaborateur.GUID).
sb.AppendLine();
sb.AppendLine("── RDV COLLAB JOIN VALIDATION ──");
DumpQuery(connection, sb,
"SELECT RDV_Collaborateur.RVC_PKID, RDV_Collaborateur.RVC_GUID_Colb, Collaborateur.GUID, Collaborateur.CO_Nom " +
"FROM RDV_Collaborateur LEFT OUTER JOIN Collaborateur ON RDV_Collaborateur.RVC_GUID_Colb = Collaborateur.GUID", null);
// 5. Validation sémantique CR_IDRdv.
sb.AppendLine();
sb.AppendLine("── CR_IDRDV SEMANTICS ──");
DumpQuery(connection, sb,
"SELECT CompteRendu.CR_PKID, CompteRendu.CR_IDRdv, CompteRendu.CR_GUID_Rdv, CompteRendu.CR_IDIntervention, " +
"RDV_Collaborateur.RVC_PKID, RDV_Planning.GUID " +
"FROM CompteRendu LEFT OUTER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_PKID = CompteRendu.CR_IDRdv " +
"LEFT OUTER JOIN RDV_Planning ON RDV_Planning.GUID = RDV_Collaborateur.RVC_GUID_RDV", null);
// 6. Investigation du dédoublement de rôles (ContactsDonneursSql).
sb.AppendLine();
sb.AppendLine("── IP_CleRole / IP_IDRole ──");
DumpQuery(connection, sb,
"SELECT Intervention_Personne.IP_PKID, Intervention_Personne.IP_CleRole, Intervention_Personne.IP_IDRole " +
"FROM Intervention_Personne WHERE IP_IDIntervention = ?", interventionId);
sb.AppendLine();
sb.AppendLine("── ParametreMetier PA_Cle vide (rôles) ──");
DumpQuery(connection, sb,
"SELECT COUNT(*) AS CNT FROM ParametreMetier WHERE PA_Cle = ''", null);
await File.WriteAllTextAsync(OutputPath, sb.ToString());
}
private static void DumpQuery(OleDbConnection connection, StringBuilder sb, string sql, long? param)
{
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
if (param is not null)
{
cmd.Parameters.Add(new OleDbParameter { Value = param.Value });
}
using var reader = cmd.ExecuteReader();
int fieldCount = reader.FieldCount;
var colNames = new string[fieldCount];
for (int f = 0; f < fieldCount; f++) colNames[f] = reader.GetName(f);
sb.AppendLine($" COLUMNS: {string.Join(", ", colNames)}");
int rows = 0;
while (reader.Read() && rows < 8)
{
var values = new string[fieldCount];
for (int f = 0; f < fieldCount; f++)
{
values[f] = reader.IsDBNull(f) ? "<null>" : Truncate(reader.GetValue(f)?.ToString() ?? "");
}
sb.AppendLine($" ROW: {string.Join(" | ", values)}");
rows++;
}
sb.AppendLine($" ROWS (shown): {rows}");
}
catch (Exception ex)
{
sb.AppendLine($" ERROR: {Redact(ex.Message)}");
}
}
private static long ReadInt64(OleDbDataReader reader, int i)
{
try { return reader.IsDBNull(i) ? 0 : Convert.ToInt64(reader.GetValue(i)); }
catch { return 0; }
}
private static string Str(OleDbDataReader reader, int i)
{
try { return reader.IsDBNull(i) ? "<null>" : Truncate(reader.GetValue(i)?.ToString() ?? ""); }
catch { return "<err>"; }
}
private static string Truncate(string s) => s.Length <= 60 ? s : s[..60] + "…";
private static string Redact(string msg)
{
msg = msg.Replace("Password", "[PWD]", StringComparison.OrdinalIgnoreCase);
return msg;
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath)) return null;
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch { return null; }
}
// === SQL identique à OmikaRapportChampRepository (copie fidèle) ===
private const string InterventionSql =
"SELECT Intervention.IN_Titre AS IN_Titre, Intervention.IN_RefInterne AS IN_RefInterne, " +
"Intervention.CreationDate AS IN_CreationDate, Intervention.IN_NumDevis AS IN_NumDevis, " +
"Intervention.IN_DateDevis, Intervention.IN_NumFacture, Intervention.IN_DateFacture, " +
"Intervention.IN_RefExterne AS IN_RefExterne, Intervention.IN_Description AS IN_Description, " +
"Intervention.IN_DureePrevue, " +
"CONCAT(Collaborateur.CO_Prenom, ' ', Collaborateur.CO_Nom) AS IN_CO_Nom, " +
"PA_TypeInter.PA_Libelle AS IN_PA_Type, Lieu.LI_CodeLieu AS LI_CodeLieu, " +
"CONCAT(AdLieu.AD_Adresse, ' ', AdLieu.AD_CodePostal, ' ', AdLieu.AD_Ville) AS LI_Adresse, " +
"Lieu.LI_CodeAcces AS LI_CodeAcces, Client.CL_Nom AS CL_Nom, " +
"CONCAT(AdClient.AD_Adresse, ' ', AdClient.AD_CodePostal, ' ', AdClient.AD_Ville) AS AD_Presentation, " +
"Societe.SO_Nom " +
"FROM Intervention " +
"LEFT OUTER JOIN Lieu ON Intervention.IN_IDLieu = Lieu.LI_PKID " +
"LEFT OUTER JOIN Adresse AdLieu ON Lieu.LI_IDAdresse = AdLieu.AD_PKID " +
"LEFT OUTER JOIN Dossier ON Intervention.IN_IDDossier = Dossier.DO_PKID " +
"LEFT OUTER JOIN Client ON Dossier.DO_IDClient = Client.CL_PKID " +
"LEFT OUTER JOIN Adresse AdClient ON Client.CL_IDAdressePrincipale = AdClient.AD_PKID " +
"LEFT OUTER JOIN Collaborateur ON Intervention.IN_IDSuiviPar = Collaborateur.CO_PKID " +
"LEFT OUTER JOIN Societe ON Intervention.IN_IDSociete = Societe.SO_PKID " +
"LEFT OUTER JOIN ParametreMetier PA_TypeInter ON Intervention.IN_CleType = PA_TypeInter.PA_CLE " +
"WHERE Intervention.IN_PKID = ?";
private const string DossierSql =
"SELECT Dossier.DO_Titre AS DOSSIER_TITRE, Dossier.DO_RefExterne AS DOSSIER_REFEXT, " +
"Dossier.DO_NumDossier AS DOSSIER_NUM, Dossier.DO_DateDebut AS DOSSIER_DATE, " +
"PA_StatutDossier.PA_Libelle AS DOSSIER_STATUT " +
"FROM Dossier INNER JOIN Intervention ON Intervention.IN_IDDossier = Dossier.DO_PKID " +
"LEFT OUTER JOIN ParametreMetier PA_StatutDossier ON Dossier.DO_CleStatut = PA_StatutDossier.PA_CLE " +
"WHERE Intervention.IN_PKID = ?";
private const string ClientAdressesSql =
"SELECT CONCAT(Adresse.AD_Adresse, ' ', Adresse.AD_CodePostal, ' ', Adresse.AD_Ville) AS Adresse " +
"FROM Client INNER JOIN Intervention ON Intervention.IN_IDClient = Client.CL_PKID " +
"LEFT JOIN Client_Adresse ON Client_Adresse.CA_IDClient = Client.CL_PKID " +
"INNER JOIN Adresse ON Adresse.AD_PKID = Client_Adresse.CA_IDAdresse " +
"WHERE Intervention.IN_PKID = ?";
private const string CompteRenduSql =
"SELECT CompteRendu.GUID, CompteRendu.CR_DateHeureDebut, CompteRendu.CR_DateHeureFin, " +
"CompteRendu.CR_NomSignataire, CompteRendu.CR_NomSignataireTech " +
"FROM CompteRendu " +
"INNER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_PKID = CompteRendu.CR_IDRdv " +
"INNER JOIN RDV_Planning ON RDV_Planning.GUID = RDV_Collaborateur.RVC_GUID_RDV " +
"WHERE RDV_Collaborateur.Archive = 0 AND RDV_Planning.Archive = 0 AND RDV_Planning.RV_IdInter = ?";
private const string RdvSql =
"SELECT RDV_Planning.GUID, RDV_Collaborateur.RVC_DateDeb, RDV_Collaborateur.RVC_DateFin, " +
"RDV_Planning.RV_Objet, RDV_Planning.RV_Description, RDV_Planning.RV_Groupe, " +
"CONCAT(Collaborateur.CO_Prenom, ' ', Collaborateur.CO_Nom) AS PL_CO_NOM " +
"FROM RDV_Planning " +
"INNER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_GUID_RDV = RDV_Planning.GUID " +
"LEFT OUTER JOIN Collaborateur ON RDV_Collaborateur.RVC_GUID_Colb = Collaborateur.GUID " +
"WHERE RDV_Planning.Archive = 0 AND RDV_Collaborateur.Archive = 0 AND RDV_Planning.RV_IdInter = ? " +
"ORDER BY RDV_Collaborateur.RVC_DateDeb";
private const string ContactsSiteSql =
"SELECT Personne.PE_PKID, Personne.PE_Nom, Personne.PE_Prenom, Personne.PE_TelFixe, " +
"Personne.PE_TelMobile, Intervention_Personne.IP_Etage, ParametreMetier.PA_Libelle AS IP_Role " +
"FROM Intervention_Personne " +
"INNER JOIN Intervention ON Intervention.IN_PKID = Intervention_Personne.IP_IDIntervention " +
"INNER JOIN Personne ON Personne.PE_PKID = Intervention_Personne.IP_IDPersonne " +
"LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Intervention_Personne.IP_CleRole " +
"WHERE Intervention_Personne.IP_IDIntervention = ? AND Intervention_Personne.IP_IDLieu = Intervention.IN_IDLieu " +
"ORDER BY Intervention_Personne.IP_PKID";
private const string ContactsDonneursSql =
"SELECT Personne.PE_PKID, Personne.PE_Nom, Personne.PE_Prenom, Personne.PE_TelFixe, " +
"Personne.PE_TelMobile, Intervention_Personne.IP_Etage, ParametreMetier.PA_Libelle AS IP_Role " +
"FROM Intervention_Personne " +
"INNER JOIN Intervention ON Intervention.IN_PKID = Intervention_Personne.IP_IDIntervention " +
"INNER JOIN Personne ON Personne.PE_PKID = Intervention_Personne.IP_IDPersonne " +
"LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Intervention_Personne.IP_CleRole " +
"WHERE Intervention_Personne.IP_IDIntervention = ? AND Intervention_Personne.IP_IDClient = Intervention.IN_IDClient " +
"ORDER BY Intervention_Personne.IP_PKID";
private const string PhotosSql =
"SELECT Document.DC_PKID, Document.GUID, Document.DC_Nom, Document.DC_DateFichier, Document.DC_Chemin, " +
"ParametreMetier.PA_Libelle AS CAT_Libelle " +
"FROM Document " +
"LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Document.DC_CleCategorie " +
"WHERE Document.DC_IDIntervention = ? ORDER BY Document.DC_PKID, Document.DC_DateFichier";
}
@@ -0,0 +1,150 @@
using System.Data.OleDb;
using System.Text;
using System.Text.Json;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
/// <summary>
/// Sonde RUNTIME (lecture seule) : résout la racine documentaire et un échantillon
/// de photos Omika pour valider le chemin physique réel. N'écrit jamais en base.
/// Résultats dans artifacts/runtime-photopath.txt.
/// </summary>
public class PhotoPathRuntimeTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string OutputPath = @"D:\Avalonia\Omika\artifacts\runtime-photopath.txt";
[Fact]
public async Task Runtime_PhotoPath_Resolve()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA PHOTO PATH — RUNTIME PROBE (read-only) ===");
var config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await File.WriteAllTextAsync(OutputPath, sb.ToString());
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
using var connection = (OleDbConnection)factory.Create(options);
try
{
connection.Open();
sb.AppendLine("CONNECTION: OPEN");
// 1. Racine documentaire.
string root = Scalar(connection, "SELECT PAP_Valeur FROM ParametreApplication WHERE PAP_Code = 'CHEMIN_REPERTOIRE_BASE_DOCUMENTS'");
sb.AppendLine($"DOCUMENT ROOT = {root}");
// 2. Un échantillon de documents (photos par extension).
var photos = Query(connection,
"SELECT DC_PKID, DC_Nom, DC_Chemin, DC_CleCategorie FROM Document " +
"WHERE DC_Nom LIKE '%.jpg' OR DC_Nom LIKE '%.jpeg' OR DC_Nom LIKE '%.png' OR DC_Nom LIKE '%.bmp'");
sb.AppendLine($"PHOTO CANDIDATES = {photos.Count}");
foreach (var p in photos.Take(6))
{
string pkid = p.GetValueOrDefault("DC_PKID", "");
string nom = p.GetValueOrDefault("DC_Nom", "");
string chemin = p.GetValueOrDefault("DC_Chemin", "");
string candidat = Combine(root, chemin, nom);
sb.AppendLine($" DC_PKID={pkid} DC_Nom={nom} DC_Chemin={chemin} candidate={candidat} exists={File.Exists(candidat)}");
}
}
catch (Exception ex)
{
sb.AppendLine($"ERROR: {ex.GetType().Name} {ex.Message}");
}
finally
{
await File.WriteAllTextAsync(OutputPath, sb.ToString());
}
}
private static string Scalar(OleDbConnection c, string sql)
{
using var cmd = new OleDbCommand(sql, c);
return cmd.ExecuteScalar()?.ToString() ?? "<null>";
}
private static List<Dictionary<string, string>> Query(OleDbConnection c, string sql)
{
var result = new List<Dictionary<string, string>>();
using var cmd = new OleDbCommand(sql, c);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>();
for (int i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? "" : reader.GetValue(i)?.ToString() ?? "";
}
result.Add(row);
}
return result;
}
private static string Combine(string root, string chemin, string nom)
{
if (string.IsNullOrWhiteSpace(root)) return "<no-root>";
string combined = string.IsNullOrWhiteSpace(chemin)
? nom
: System.IO.Path.Combine(chemin.Trim('\\', '/'), nom);
try
{
return System.IO.Path.GetFullPath(System.IO.Path.Combine(root, combined));
}
catch
{
return "<invalid>";
}
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath)) return null;
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch { return null; }
}
}
@@ -0,0 +1,339 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.Core.Contracts.Documents;
using Omika.Core.Interfaces;
using Omika.Core.Services;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// SONDE DIAGNOSTIC (lecture seule) du MODÈLE EMAIL OMIKA : charge un vrai
/// <c>MailModele</c> d'OMIKA_TEST, sépare sujet/corps, liste les rubriques
/// <c>GetChampsMailAsync</c>, puis reproduit la substitution
/// <c>EmailCreationService</c> pour exposer la première divergence fonctionnelle.
/// N'écrit JAMAIS en base. Résultats dans artifacts/diag-email.txt.
/// </summary>
public class RapportEmailDiagnosticTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string ArtifactDir = @"D:\Avalonia\Omika\artifacts";
[Fact]
public async Task Diagnostic_Trace_Email_Omika()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA EMAIL — DIAGNOSTIC (read-only) ===");
TestConfig? config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await WriteArtifact("diag-email.txt", sb);
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
try
{
await connection.OpenAsync();
sb.AppendLine($"CONNECTION: OPEN (DB={config.Omika.Database})");
}
catch (Exception ex)
{
sb.AppendLine($"CONNECTION FAILED (skip): {ex.Message}");
await WriteArtifact("diag-email.txt", sb);
return;
}
try
{
// 1. Liste des modèles email.
IReadOnlyList<IReadOnlyDictionary<string, object?>> modeles = await connection.QueryAsync(
"SELECT MM_PKID, MM_Nom, MM_Sujet, MM_MsgDoc, MM_Extra, MM_EnvoiMob FROM MailModele ORDER BY MM_PKID");
sb.AppendLine($"MAIL MODELES = {modeles.Count}");
long modeleChoisi = 0;
byte[]? corpsChoisi = null;
string? sujetChoisi = null;
string? extraChoisi = null;
foreach (var m in modeles)
{
byte[]? docx = m.GetValueOrDefault("MM_MsgDoc") as byte[];
string? sujet = m.GetValueOrDefault("MM_Sujet")?.ToString();
string? extra = m.GetValueOrDefault("MM_Extra")?.ToString();
string texte = docx is null ? string.Empty : DocxTextExtractor.Extraire(docx);
string combiné = (sujet ?? string.Empty) + "\n" + texte;
var jetons = RubriqueSubstitutionEngine.ExtractTokens(combiné);
bool interet = jetons.Count > 0;
sb.AppendLine($" MM_PKID={m.GetValueOrDefault("MM_PKID")} nom={Truncate(m.GetValueOrDefault("MM_Nom")?.ToString())} " +
$"sujet={Truncate(sujet, 40)} msgDoc={docx?.Length ?? 0}o extra={(extra is null ? "null" : extra.Length + "o")} " +
$"envoiMob={m.GetValueOrDefault("MM_EnvoiMob")} jetons={jetons.Count}");
if (interet && modeleChoisi == 0)
{
modeleChoisi = Convert.ToInt64(m.GetValueOrDefault("MM_PKID"));
corpsChoisi = docx;
sujetChoisi = sujet;
extraChoisi = extra;
}
// Préfère le modèle qui a un MM_Extra non vide (rubriques EXTRA réelles).
if (!string.IsNullOrWhiteSpace(extra))
{
modeleChoisi = Convert.ToInt64(m.GetValueOrDefault("MM_PKID"));
corpsChoisi = docx;
sujetChoisi = sujet;
extraChoisi = extra;
}
}
if (modeleChoisi == 0)
{
sb.AppendLine("NO MAIL MODEL WITH PLACEHOLDERS (skip).");
await WriteArtifact("diag-email.txt", sb);
return;
}
// 2. Modèle choisi : sujet + corps + extra.
string corpsTexte = corpsChoisi is null ? string.Empty : DocxTextExtractor.Extraire(corpsChoisi);
sb.AppendLine();
sb.AppendLine($"CHOSEN MM_PKID={modeleChoisi}");
sb.AppendLine($"MM_EXTRA = {extraChoisi}");
sb.AppendLine("── MM_EXTRA PARSED ──");
if (!string.IsNullOrWhiteSpace(extraChoisi))
{
foreach (RapportChampDto x in RubriqueExtraMobParser.Parser(extraChoisi))
{
sb.AppendLine($" extra rub={x.Rubrique} nom={Truncate(x.Nom, 25)} type={x.Type} typeMembre={x.TypeMembre} masque={x.MasqueSaisie} groupe={x.GroupeApp}");
}
}
sb.AppendLine($"── SUJET (brut) ──");
sb.AppendLine(sujetChoisi ?? "<null>");
sb.AppendLine($"── SUJET PLACEHOLDERS ──");
foreach (string j in RubriqueSubstitutionEngine.ExtractTokens(sujetChoisi))
{
sb.AppendLine($" ${j}$");
}
sb.AppendLine($"── CORPS PLACEHOLDERS ──");
foreach (string j in RubriqueSubstitutionEngine.ExtractTokens(corpsTexte))
{
sb.AppendLine($" ${j}$");
}
sb.AppendLine($"── CORPS (extrait, {corpsTexte.Length} chars) ──");
sb.AppendLine(corpsTexte.Length <= 1500 ? corpsTexte : corpsTexte[..1500] + "…");
// Structure DOCX : quels placeholders sont dans un TableRow (w:tbl) vs un paragraphe.
sb.AppendLine();
sb.AppendLine("── DOCX STRUCTURE (placeholder → dans table ?) ──");
if (corpsChoisi is not null)
{
await File.WriteAllBytesAsync(Path.Combine(ArtifactDir, "diag-email-modele.docx"), corpsChoisi);
using var docxStream = new MemoryStream(corpsChoisi);
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Open(docxStream, false);
var bodyEl = doc.MainDocumentPart!.Document.Body!;
foreach (var tr in bodyEl.Descendants<DocumentFormat.OpenXml.Wordprocessing.TableRow>())
{
string txt = string.Concat(tr.Descendants<DocumentFormat.OpenXml.Wordprocessing.Text>().Select(t => t.Text));
if (txt.Contains('$') || txt.Contains('<'))
{
sb.AppendLine($" TABLE ROW: {Truncate(txt, 120)}");
}
}
foreach (var p in bodyEl.Descendants<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
string txt = string.Concat(p.Descendants<DocumentFormat.OpenXml.Wordprocessing.Text>().Select(t => t.Text));
if (txt.Contains('$') || txt.Contains('<'))
{
bool dansTable = p.Ancestors<DocumentFormat.OpenXml.Wordprocessing.Table>().Any();
sb.AppendLine($" PARAGRAPH (dansTable={dansTable}): {Truncate(txt, 120)}");
}
}
}
// 3. Intervention avec données (photos etc.) — première intervention.
long interventionId = 0;
IReadOnlyList<IReadOnlyDictionary<string, object?>> intervs = await connection.QueryAsync(
"SELECT IN_PKID FROM Intervention");
foreach (var i in intervs)
{
interventionId = Convert.ToInt64(i.GetValueOrDefault("IN_PKID"));
break;
}
var parameters = new FakeApplicationParameters("C:\\Documents_Demo");
var repository = new OmikaRapportChampRepository(
connection, NullLogger<OmikaRapportChampRepository>.Instance, parameters);
// 4. GetChampsMailAsync réel.
IReadOnlyList<RapportChampDto> champs = await repository.GetChampsMailAsync(modeleChoisi, interventionId);
sb.AppendLine();
sb.AppendLine($"── GetChampsMailAsync ({champs.Count}) ──");
foreach (RapportChampDto c in champs)
{
sb.AppendLine($" rub={c.Rubrique} nom={Truncate(c.Nom, 25)} groupe={c.GroupeApp} " +
$"type={c.Type} typeMembre={c.TypeMembre} masque={c.MasqueSaisie} modif={c.Modifiable} " +
$"bd={c.DisponibleBase} listeMembre={c.ListeMembre.Count} " +
$"bdVal={(c.ListeDonnesBD is null ? "null" : c.ListeDonnesBD.Length + "o")} " +
$"mobVal={(c.ListeDonnesMob is null ? "null" : c.ListeDonnesMob.Length + "o")}");
foreach (RapportChampSousElementDto m in c.ListeMembre)
{
sb.AppendLine($" membre rub={m.Rubrique} type={m.Type} membreMob={m.MembreMob}");
}
if (!string.IsNullOrWhiteSpace(c.ListeDonnesBD) && c.ListeDonnesBD.Length < 300)
{
sb.AppendLine($" ListeDonnesBD = {c.ListeDonnesBD}");
}
}
// 5. Reproduction via le VRAI moteur Email (EmailDocxRenderer + DocxHtmlConverter).
string sujetFinal = SubstituerSujet(sujetChoisi, champs);
byte[] docxRendu = EmailDocxRenderer.Rendre(corpsChoisi ?? [], champs);
string corpsFinal = DocxHtmlConverter.Convertir(docxRendu);
string corpsTexteFinal = DocxTextExtractor.Extraire(docxRendu);
sb.AppendLine();
sb.AppendLine("── SUBJECT FINAL ──");
sb.AppendLine(sujetFinal);
sb.AppendLine("── BODY FINAL (HTML extrait) ──");
sb.AppendLine(corpsFinal.Length <= 1500 ? corpsFinal : corpsFinal[..1500] + "…");
// Restants.
sb.AppendLine();
sb.AppendLine("── UNRESOLVED $...$ (subject + corps texte rendu) ──");
var restants = RubriqueSubstitutionEngine.ExtractTokens(sujetFinal + "\n" + corpsTexteFinal);
foreach (string r in restants)
{
sb.AppendLine($" ${r}$");
}
sb.AppendLine($"UNRESOLVED COUNT = {restants.Count}");
sb.AppendLine($"BODY IS HTML = {(corpsFinal.Contains("<table", StringComparison.OrdinalIgnoreCase) || corpsFinal.Contains("<p", StringComparison.OrdinalIgnoreCase) ? "YES" : "NO")}");
sb.AppendLine($"RAW JSON IN EMAIL = {(sujetFinal.Contains('{') && sujetFinal.Contains('}') || corpsFinal.Contains("\"cheminComplet\"") ? "YES" : "NO")}");
await WriteArtifact("diag-email.txt", sb);
}
catch (Exception ex)
{
sb.AppendLine();
sb.AppendLine($"ERROR: {ex.GetType().Name}: {ex}");
await WriteArtifact("diag-email.txt", sb);
}
}
private static string SubstituerSujet(string? sujet, IReadOnlyList<RapportChampDto> champs)
{
if (string.IsNullOrEmpty(sujet))
{
return sujet ?? string.Empty;
}
var remplacements = new Dictionary<string, string>(StringComparer.Ordinal);
var connus = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (RapportChampDto champ in champs)
{
if (string.IsNullOrWhiteSpace(champ.Rubrique))
{
continue;
}
connus.Add(champ.Rubrique);
foreach (RapportChampSousElementDto m in champ.ListeMembre)
{
if (!string.IsNullOrWhiteSpace(m.Rubrique)) connus.Add(m.Rubrique);
}
if (champ.ListeMembre is { Count: > 0 })
{
remplacements[champ.Rubrique] = EmailDocxRenderer.FormaterTableauTexte(champ);
}
else
{
string? valeur = champ.ListeDonnesMob ?? champ.ListeDonnesBD;
remplacements[champ.Rubrique] = EmailDocxRenderer.FormaterValeur(valeur, champ.TypeMembre ?? champ.Type, champ.MasqueSaisie);
}
}
var builder = new StringBuilder(sujet);
foreach (var paire in remplacements)
{
if (!string.IsNullOrEmpty(paire.Key)) builder.Replace(paire.Key, paire.Value);
}
foreach (string jeton in connus)
{
builder.Replace(jeton, string.Empty);
}
return builder.ToString();
}
private static async Task WriteArtifact(string name, StringBuilder sb)
{
Directory.CreateDirectory(ArtifactDir);
await File.WriteAllTextAsync(Path.Combine(ArtifactDir, name), sb.ToString());
}
private static string Truncate(string? s, int max = 60) => s is null ? "<null>" : (s.Length <= max ? s : s[..max] + "…");
private sealed class FakeApplicationParameters : IApplicationParameterRepository
{
private readonly string _root;
public FakeApplicationParameters(string root) => _root = root;
public Task<string?> GetValueAsync(string code, CancellationToken ct = default)
=> Task.FromResult<string?>(code == "CHEMIN_REPERTOIRE_BASE_DOCUMENTS" ? _root : null);
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
{
return null;
}
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
}
@@ -0,0 +1,392 @@
using System.IO.Compression;
using System.IO.Packaging;
using System.Text;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.Core.Contracts.Documents;
using Omika.Core.Interfaces;
using Omika.Core.Services;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Omika;
using WP = DocumentFormat.OpenXml.Drawing.Wordprocessing;
namespace Omika.Tests.Connection;
/// <summary>
/// SONDE DIAGNOSTIC (lecture seule + génération en mémoire) : trace UNE génération
/// de rapport réelle de bout en bout pour expliquer pourquoi une photo sélectionnée
/// n'apparaît pas dans le PDF. Ne modifie JAMAIS la base. Écrit :
/// — artifacts/diag-photo.txt : trace textuelle complète ;
/// — artifacts/diag-modele.docx : modèle DOCX réel (copie) ;
/// — artifacts/diag-rapport.docx : DOCX intermédiaire généré (avant conversion PDF).
/// S'auto-ignore si la configuration locale est absente ou le serveur injoignable.
/// </summary>
public class RapportPhotoDiagnosticTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private const string ArtifactDir = @"D:\Avalonia\Omika\artifacts";
[Fact]
public async Task Diagnostic_Trace_Photo_Reelle()
{
var sb = new StringBuilder();
sb.AppendLine("=== OMIKA PHOTO — DIAGNOSTIC (read-only) ===");
TestConfig? config = LoadConfig();
if (config is null)
{
sb.AppendLine("CONFIG: MISSING (skip)");
await WriteArtifact("diag-photo.txt", sb);
return;
}
var options = new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = config.Omika.Database,
User = config.Omika.User,
Credential = config.Omika.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
var factory = new HfSqlOleDbConnectionFactory();
await using var connection = new HfSqlOleDbConnection(factory, options);
try
{
await connection.OpenAsync();
sb.AppendLine($"CONNECTION: OPEN (DB={config.Omika.Database})");
}
catch (Exception ex)
{
sb.AppendLine($"CONNECTION FAILED (skip): {Redact(ex.Message)}");
await WriteArtifact("diag-photo.txt", sb);
return;
}
try
{
// 1. Racine documentaire.
object? rootObj = await connection.ExecuteScalarAsync(
"SELECT PAP_Valeur FROM ParametreApplication WHERE PAP_Code = 'CHEMIN_REPERTOIRE_BASE_DOCUMENTS'");
string racine = rootObj?.ToString() ?? string.Empty;
sb.AppendLine($"DOCUMENT ROOT = {racine}");
var parameters = new FakeApplicationParameters(racine);
var repository = new OmikaRapportChampRepository(
connection, NullLogger<OmikaRapportChampRepository>.Instance, parameters);
// 2. Modèles contenant des rubriques photo.
IReadOnlyList<IReadOnlyDictionary<string, object?>> modeles = await connection.QueryAsync(
"SELECT MRP_PKID, MRP_Nom, MRP_Modele, MRP_Extra FROM ModeleRapport");
sb.AppendLine($"MODELES = {modeles.Count}");
long modelePhoto = 0;
byte[]? modeleDocx = null;
string? modeleTexte = null;
foreach (var m in modeles)
{
byte[]? docx = m.GetValueOrDefault("MRP_Modele") as byte[];
if (docx is null || docx.Length == 0)
{
continue;
}
string texte = DocxTextExtractor.Extraire(docx);
bool contientPhoto = texte.Contains("PHOTO", StringComparison.OrdinalIgnoreCase);
sb.AppendLine($" MODELE pkid={m.GetValueOrDefault("MRP_PKID")} nom={Truncate(m.GetValueOrDefault("MRP_Nom")?.ToString())} photos={contientPhoto}");
if (contientPhoto && modelePhoto == 0)
{
modelePhoto = Convert.ToInt64(m.GetValueOrDefault("MRP_PKID"));
modeleDocx = docx;
modeleTexte = texte;
}
}
if (modelePhoto == 0 || modeleDocx is null)
{
sb.AppendLine("NO PHOTO MODEL FOUND (skip).");
await WriteArtifact("diag-photo.txt", sb);
return;
}
sb.AppendLine();
sb.AppendLine($"CHOSEN MODEL pkid={modelePhoto}");
sb.AppendLine("── MODEL TEXT (photo lines) ──");
foreach (string ligne in (modeleTexte ?? string.Empty).Split('\n'))
{
if (ligne.Contains("PHOTO", StringComparison.OrdinalIgnoreCase)
|| ligne.Contains("IMAGE", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($" {ligne.Trim()}");
}
}
await File.WriteAllBytesAsync(Path.Combine(ArtifactDir, "diag-modele.docx"), modeleDocx);
// 3. RubriquesDocument liées aux photos.
IReadOnlyList<IReadOnlyDictionary<string, object?>> rubriques = await connection.QueryAsync(
"SELECT RD_PKID, RD_Nom, RD_Rubrique, RD_TypeMembre, RD_MasqueSaisie, RD_Groupe, RD_MembreMob, RD_IdRubParent, RD_Ordre " +
"FROM RubriquesDocument WHERE RD_Groupe = 'PHOTO_INTER' ORDER BY RD_IdRubParent, RD_Ordre");
sb.AppendLine();
sb.AppendLine($"── RUBRIQUES PHOTO_INTER ({rubriques.Count}) ──");
foreach (var r in rubriques)
{
sb.AppendLine($" rub={r.GetValueOrDefault("RD_Rubrique")} type={r.GetValueOrDefault("RD_TypeMembre")} " +
$"masque={r.GetValueOrDefault("RD_MasqueSaisie")} membre={r.GetValueOrDefault("RD_MembreMob")} parent={r.GetValueOrDefault("RD_IdRubParent")}");
}
// 4. Intervention avec photos (celle qui possède réellement des .jpg).
IReadOnlyList<IReadOnlyDictionary<string, object?>> interventionsAvecPhotos = await connection.QueryAsync(
"SELECT DISTINCT DC_IDIntervention FROM Document WHERE DC_Nom LIKE '%.jpg' OR DC_Nom LIKE '%.png'");
long interventionId = 0;
foreach (var i in interventionsAvecPhotos)
{
interventionId = Convert.ToInt64(i.GetValueOrDefault("DC_IDIntervention"));
break;
}
if (interventionId == 0)
{
IReadOnlyList<IReadOnlyDictionary<string, object?>> toutes = await connection.QueryAsync(
"SELECT IN_PKID FROM Intervention");
foreach (var i in toutes)
{
interventionId = Convert.ToInt64(i.GetValueOrDefault("IN_PKID"));
break;
}
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> photos = await connection.QueryAsync(
"SELECT Document.DC_PKID, Document.GUID, Document.DC_Nom, Document.DC_DateFichier, Document.DC_Chemin, " +
"ParametreMetier.PA_Libelle AS CAT_Libelle " +
"FROM Document LEFT OUTER JOIN ParametreMetier ON ParametreMetier.PA_CLE = Document.DC_CleCategorie " +
"AND Document.DC_CleCategorie <> '' WHERE Document.DC_IDIntervention = ? ORDER BY Document.DC_PKID",
[interventionId]);
sb.AppendLine();
sb.AppendLine($"INTERVENTION pkid={interventionId} photosBrutes={photos.Count}");
foreach (var p in photos.Take(5))
{
sb.AppendLine($" DC_PKID={p.GetValueOrDefault("DC_PKID")} nom={p.GetValueOrDefault("DC_Nom")} cat={p.GetValueOrDefault("CAT_Libelle")}");
}
// 5. Champs complets via le repository (équivalent serveur).
IReadOnlyList<RapportChampDto> champs = await repository.GetChampsAsync(
modelePhoto, interventionId, toutesRub: true);
sb.AppendLine();
sb.AppendLine($"── CHAMPS ({champs.Count}) ──");
foreach (RapportChampDto c in champs)
{
bool photo = c.Rubrique?.Contains("PHOTO", StringComparison.OrdinalIgnoreCase) == true
|| string.Equals(c.Type, "ChpImage", StringComparison.OrdinalIgnoreCase);
if (photo)
{
sb.AppendLine($" rub={c.Rubrique} type={c.Type} typeMembre={c.TypeMembre} membre={c.MembreMob} " +
$"listeMembre={c.ListeMembre.Count} listeDonnesBD={(c.ListeDonnesBD ?? "").Length} chars");
foreach (RapportChampSousElementDto m in c.ListeMembre)
{
sb.AppendLine($" membre: rub={m.Rubrique} type={m.Type} typeMembre={m.TypeMembre} membreMob={m.MembreMob}");
}
if (!string.IsNullOrWhiteSpace(c.ListeDonnesBD))
{
sb.AppendLine($" listeDonnesBD JSON = {Truncate(c.ListeDonnesBD, 800)}");
}
}
}
// 6. Photo sélectionnée (première option photo).
RapportChampDto? photoChamp = champs.FirstOrDefault(c =>
string.Equals(c.Type, "ChpImage", StringComparison.OrdinalIgnoreCase)
|| c.Rubrique?.Contains("PHOTO", StringComparison.OrdinalIgnoreCase) == true);
string? cheminComplet = null;
if (photoChamp is not null)
{
if (photoChamp.ListeMembre is { Count: > 0 })
{
// TABPHOTO : ListeDonnesBD = JSON array.
using JsonDocument doc = JsonDocument.Parse(photoChamp.ListeDonnesBD ?? "[]");
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in doc.RootElement.EnumerateArray())
{
if (item.TryGetProperty("cheminComplet", out JsonElement cc) && cc.ValueKind == JsonValueKind.String)
{
cheminComplet = cc.GetString();
break;
}
}
}
}
else
{
cheminComplet = photoChamp.ListeDonnesBD;
}
}
sb.AppendLine();
sb.AppendLine($"PHOTO SELECTED = {(cheminComplet is null ? "NO" : "YES")}");
sb.AppendLine($"CHEMINCOMPLET = {cheminComplet}");
sb.AppendLine($"FILE EXISTS = {(cheminComplet is null ? "N/A" : File.Exists(cheminComplet))}");
if (cheminComplet is not null && File.Exists(cheminComplet))
{
byte[] premiers = new byte[Math.Min(12, new FileInfo(cheminComplet).Length)];
using (FileStream fs = File.OpenRead(cheminComplet))
{
_ = fs.Read(premiers, 0, premiers.Length);
}
sb.AppendLine($"MAGIC BYTES = {BitConverter.ToString(premiers)}");
}
// 7. Génération du DOCX intermédiaire (vraie image fichier).
sb.AppendLine();
sb.AppendLine("── RemplirRubriques ──");
byte[] docxRempli = RapportPdfService.RemplirRubriques(modeleDocx, champs);
await File.WriteAllBytesAsync(Path.Combine(ArtifactDir, "diag-rapport.docx"), docxRempli);
sb.AppendLine($"INTERMEDIATE DOCX = {docxRempli.Length} bytes");
InspecterDocx(sb, docxRempli, modeleTexte ?? string.Empty, champs);
await WriteArtifact("diag-photo.txt", sb);
}
catch (Exception ex)
{
sb.AppendLine();
sb.AppendLine($"ERROR: {ex.GetType().Name}: {ex}");
await WriteArtifact("diag-photo.txt", sb);
}
}
private static void InspecterDocx(
StringBuilder sb,
byte[] docx,
string modeleTexte,
IReadOnlyList<RapportChampDto> champs)
{
using var stream = new MemoryStream(docx);
using WordprocessingDocument document = WordprocessingDocument.Open(stream, false);
MainDocumentPart? main = document.MainDocumentPart;
if (main is null)
{
sb.AppendLine("INSPECT: MainDocumentPart missing");
return;
}
// Images dans le package (sous /word/media/).
sb.AppendLine();
sb.AppendLine("── word/media/* ──");
using (var pkgStream = new MemoryStream(docx))
using (Package package = Package.Open(pkgStream, FileMode.Open, FileAccess.Read))
{
var mediaParts = package.GetParts()
.Where(p => p.Uri.OriginalString.StartsWith("/word/media/", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var mediaPart in mediaParts)
{
long bytes = 0;
try { using var s = mediaPart.GetStream(); bytes = s.Length; } catch { }
sb.AppendLine($" media: uri={mediaPart.Uri} contentType={mediaPart.ContentType} bytes={bytes}");
}
sb.AppendLine($"IMAGE PARTS = {mediaParts.Count}");
// Relations d'images (document.xml.rels).
sb.AppendLine();
sb.AppendLine("── document.xml.rels (image relationships) ──");
var imageRelations = package
.GetPart(new Uri("/word/document.xml", UriKind.Relative))
.GetRelationshipsByType("http://schemas.openxmlformats.org/officeDocument/2006/relationships/image")
.ToList();
foreach (var rel in imageRelations)
{
sb.AppendLine($" relId={rel.Id} target={rel.TargetUri}");
}
}
sb.AppendLine();
sb.AppendLine("── Drawing OpenXML ──");
int drawings = main.Document.Body!.Descendants<Drawing>().Count();
var extents = main.Document.Body.Descendants<WP.Extent>()
.Select(e => $"{e.Cx!.Value}x{e.Cy!.Value}").ToList();
sb.AppendLine($"DRAWINGS = {drawings}");
foreach (string extent in extents.Take(10))
{
sb.AppendLine($" extent: {extent}");
}
// Placeholder restants.
sb.AppendLine();
sb.AppendLine("── PLACEHOLDER REMAINS ──");
string texte = string.Concat(main.Document.Body.Descendants<Text>().Select(t => t.Text));
foreach (RapportChampDto c in champs)
{
if (c.Rubrique?.Contains("PHOTO", StringComparison.OrdinalIgnoreCase) == true
|| string.Equals(c.Type, "ChpImage", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($" rub={c.Rubrique} remains={texte.Contains(c.Rubrique ?? "", StringComparison.Ordinal)}");
}
}
}
private static async Task WriteArtifact(string name, StringBuilder sb)
{
Directory.CreateDirectory(ArtifactDir);
await File.WriteAllTextAsync(Path.Combine(ArtifactDir, name), sb.ToString());
}
private static string Truncate(string? s, int max = 60) => s is null ? "<null>" : (s.Length <= max ? s : s[..max] + "…");
private static string Redact(string msg) => msg.Replace("Password", "[PWD]", StringComparison.OrdinalIgnoreCase);
private sealed class FakeApplicationParameters : IApplicationParameterRepository
{
private readonly string _root;
public FakeApplicationParameters(string root) => _root = root;
public Task<string?> GetValueAsync(string code, CancellationToken ct = default)
=> Task.FromResult<string?>(code == "CHEMIN_REPERTOIRE_BASE_DOCUMENTS" ? _root : null);
}
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
{
return null;
}
try
{
return JsonSerializer.Deserialize<TestConfig>(
File.ReadAllText(ConfigPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch
{
return null;
}
}
}
@@ -0,0 +1,150 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Omika.Core.Interfaces.Mobile;
using Omika.Data;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Repositories.Technical;
namespace Omika.Tests.Connection;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddOmikaData_RegistersWebServiceConnectionAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IWebServiceHfSqlConnection));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
[Fact]
public void AddOmikaDataServiceConnection_RegistersServiceConnectionAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaDataServiceConnection();
var descriptor = services.Single(d => d.ServiceType == typeof(IServiceHfSqlConnection));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
[Fact]
public void AddOmikaData_RegistersSyncTransferRepositoryAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(ISyncTransferRepository));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(SyncTransferRepository), descriptor.ImplementationType);
}
[Fact]
public void AddOmikaDataSyncConnection_DoesNotRegisterSyncTransferRepository()
{
// L'ancienne connexion Sync unique ne doit plus brancher le repository :
// le repository est désormais multi-connexion (Service + WebService).
var services = new ServiceCollection();
services.AddOmikaDataSyncConnection();
Assert.DoesNotContain(services, d => d.ServiceType == typeof(ISyncTransferRepository));
}
[Fact]
public async Task AddOmikaData_ResolvesSyncTransferRepository_WithBothConnections()
{
// Vérifie que le DI résout ISyncTransferRepository avec les deux connexions
// explicites (Service pour TOSRV, WebService pour MobReception/MobLog).
var services = new ServiceCollection();
services.AddLogging();
// IConfiguration est requis par les Configure<IConfiguration> des options
// (HfsqlOptions, ServiceConnectionOptions, etc.) lors de la résolution.
// La section ServiceConnection doit être valide pour que le validateur
// (ServiceConnectionOptionsValidator) accepte la résolution de la connexion
// Service. Le mot de passe reste volontairement absent (optionnel).
var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
// Connexion Service (base TOSRV) : mot de passe volontairement absent
// (optionnel : null/vide/renseigné sont tous valides).
[$"{ServiceConnectionOptions.SectionName}:Server"] = "localhost",
[$"{ServiceConnectionOptions.SectionName}:Port"] = "49000",
[$"{ServiceConnectionOptions.SectionName}:DatabaseName"] = "BATIK.01_BLS",
[$"{ServiceConnectionOptions.SectionName}:Login"] = "service",
// Connexion WebService/Business (base MobReception/MobLog) : la
// résolution de IHfsqlConnection exige des options Hfsql valides.
[$"{HfsqlOptions.SectionName}:Server"] = "localhost",
[$"{HfsqlOptions.SectionName}:Port"] = "49000",
[$"{HfsqlOptions.SectionName}:DatabaseName"] = "BATIK_TEST",
[$"{HfsqlOptions.SectionName}:Login"] = "service"
})
.Build();
services.AddSingleton<Microsoft.Extensions.Configuration.IConfiguration>(configuration);
services.AddOmikaData();
services.AddOmikaDataServiceConnection();
using var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<ISyncTransferRepository>();
Assert.IsType<SyncTransferRepository>(repo);
// Les deux connexions explicites sont résolvables dans le même scope.
var service = scope.ServiceProvider.GetRequiredService<IServiceHfSqlConnection>();
var web = scope.ServiceProvider.GetRequiredService<IWebServiceHfSqlConnection>();
Assert.NotNull(service);
Assert.NotNull(web);
}
[Fact]
public void AddOmikaData_RegistersHfsqlConnectionAsScoped()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnection));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
}
[Fact]
public void AddOmikaData_RegistersConnectionFactoryAsSingleton()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IHfsqlConnectionFactory));
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
Assert.Equal(typeof(OdbcConnectionFactory), descriptor.ImplementationType);
}
[Fact]
public void AddOmikaData_RegistersOptionsValidator()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IValidateOptions<HfsqlOptions>));
Assert.Equal(typeof(HfsqlOptionsValidator), descriptor.ImplementationType);
}
[Fact]
public void AddOmikaData_RegistersHfsqlOptions()
{
var services = new ServiceCollection();
services.AddOmikaData();
var descriptor = services.Single(d => d.ServiceType == typeof(IConfigureOptions<HfsqlOptions>));
Assert.NotNull(descriptor);
}
}
@@ -0,0 +1,158 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class ServiceConnectionOptionsValidatorTests
{
private static ServiceConnectionOptions CreateValidOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BATIK.01_BLS",
Login = "susana"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = ServiceConnectionOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = CreateValidOptions();
options.Server = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidPort_ReturnsFailure()
{
var options = CreateValidOptions();
options.Port = 0;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabase_ReturnsFailure()
{
var options = CreateValidOptions();
options.DatabaseName = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingLogin_ReturnsFailure()
{
var options = CreateValidOptions();
options.Login = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("compte", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_NullPassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = null;
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_EmptyPassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = "";
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_WhitespacePassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = " ";
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_SetPassword_IsAccepted()
{
var options = CreateValidOptions();
options.Password = "secret";
var result = ServiceConnectionOptionsValidator.Validate(options);
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new ServiceConnectionOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<ServiceConnectionOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<ServiceConnectionOptions> validator = new ServiceConnectionOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<ServiceConnectionOptions> validator = new ServiceConnectionOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ToString_DoesNotExposePassword()
{
var options = CreateValidOptions();
options.Password = "secret";
options.FilePasswords.ProtectionStd = "secret-std";
var text = options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,332 @@
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class ServiceOleDbConnectionTests
{
private static ServiceConnectionOptions CreateOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BATIK.01_BLS",
Login = "susana",
Password = "secret",
FilePasswords = new ServiceFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = null
}
};
[Fact]
public void CreateForService_MapsAllFields()
{
var options = CreateOptions();
var result = HfSqlConnectionOptionsFactory.CreateForService(options);
Assert.Equal("127.0.0.1", result.Server);
Assert.Equal(4900, result.Port);
Assert.Equal("BATIK.01_BLS", result.Database);
Assert.Equal("susana", result.User);
Assert.Equal("secret", result.Credential);
Assert.Equal("STD_PASSWORD", result.FilePasswords.ProtectionStd);
Assert.Null(result.FilePasswords.ProtectionPlus);
}
[Fact]
public void CreateForService_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlConnectionOptionsFactory.CreateForService(null!));
}
[Fact]
public void CreateForService_NullPassword_ProducesEmptyCredential()
{
var options = CreateOptions();
options.Password = null;
var result = HfSqlConnectionOptionsFactory.CreateForService(options);
// La factory normalise null en chaîne vide (cohérent avec CreateForSync
// et CreateForLicense). Le builder OLEDB n'ajoute alors pas de clé
// "Password" à la chaîne de connexion.
Assert.Equal("", result.Credential);
}
[Fact]
public void CreateForService_EmptyPassword_ProducesEmptyCredential()
{
var options = CreateOptions();
options.Password = "";
var result = HfSqlConnectionOptionsFactory.CreateForService(options);
Assert.Equal("", result.Credential);
}
[Fact]
public async Task OpenAsync_OpensConnection()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotReopen()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.OpenCount);
}
[Fact]
public async Task CloseAsync_ClosesConnection()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task QueryAsync_ReturnsRows()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM TOSRV");
Assert.Single(rows);
Assert.Equal("TS-001", rows[0]["TS_JSON"]);
}
[Fact]
public async Task ExecuteScalarAsync_ReturnsValue()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var value = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM TOSRV");
Assert.Equal(1L, value);
}
[Fact]
public async Task QueryAsync_NotOpen_Throws()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.QueryAsync("SELECT * FROM TOSRV"));
}
[Fact]
public async Task Options_ExposesConnectionName()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
Assert.Equal(ServiceConnectionOptions.ConnectionName, connection.Options.ConnectionName);
Assert.Equal("BATIK.01_BLS", connection.Options.DatabaseName);
Assert.Equal("susana", connection.Options.Login);
}
[Fact]
public async Task Options_DoesNotExposePassword()
{
var factory = new FakeFactory();
await using var connection = new ServiceOleDbConnection(CreateOptions(), factory);
var text = connection.Options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("STD_PASSWORD", text, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Fabrique simulée qui retourne une connexion factice sans serveur HFSQL.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
public int OpenCount { get; private set; }
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
return new FakeConnection(() => OpenCount++);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly Action _onOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(Action onOpen)
{
_onOpen = onOpen;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "BATIK.01_BLS";
public override string DataSource => "127.0.0.1:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
_onOpen();
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand();
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => 1L;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => new FakeReader();
}
private sealed class FakeReader : DbDataReader
{
private bool _read;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => GetValue(ordinal);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => typeof(string);
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => "TS_JSON";
public override int GetOrdinal(string name) => name == "TS_JSON" ? 0 : throw new IndexOutOfRangeException();
public override string GetString(int ordinal) => "TS-001";
public override object GetValue(int ordinal) => "TS-001";
public override int GetValues(object[] values)
{
values[0] = "TS-001";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool NextResult() => false;
public override bool Read()
{
if (_read)
return false;
_read = true;
return true;
}
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,114 @@
using Microsoft.Extensions.Options;
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class SyncConnectionOptionsValidatorTests
{
private static SyncConnectionOptions CreateValidOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_SYNC",
Login = "susana"
};
[Fact]
public void Validate_ValidOptions_ReturnsSuccess()
{
var result = SyncConnectionOptionsValidator.Validate(CreateValidOptions());
Assert.True(result.IsSuccess);
}
[Fact]
public void Validate_MissingServer_ReturnsFailure()
{
var options = CreateValidOptions();
options.Server = null;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_InvalidPort_ReturnsFailure()
{
var options = CreateValidOptions();
options.Port = 0;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("port", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingDatabase_ReturnsFailure()
{
var options = CreateValidOptions();
options.DatabaseName = null;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("base", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_MissingLogin_ReturnsFailure()
{
var options = CreateValidOptions();
options.Login = null;
var result = SyncConnectionOptionsValidator.Validate(options);
Assert.True(result.IsFailure);
Assert.Contains(result.Errors, e => e.Contains("compte", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_ImplementsIValidateOptions()
{
var validator = new SyncConnectionOptionsValidator();
Assert.IsAssignableFrom<IValidateOptions<SyncConnectionOptions>>(validator);
}
[Fact]
public void ValidateOptions_Valid_ReturnsSuccess()
{
IValidateOptions<SyncConnectionOptions> validator = new SyncConnectionOptionsValidator();
var result = validator.Validate(null, CreateValidOptions());
Assert.Equal(ValidateOptionsResult.Success, result);
}
[Fact]
public void ValidateOptions_Invalid_ReturnsFailure()
{
IValidateOptions<SyncConnectionOptions> validator = new SyncConnectionOptionsValidator();
var options = CreateValidOptions();
options.Server = null;
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, e => e.Contains("serveur", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ToString_DoesNotExposePassword()
{
var options = CreateValidOptions();
options.Password = "secret";
options.FilePasswords.ProtectionStd = "secret-std";
var text = options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,305 @@
using System.Data;
using System.Data.Common;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class SyncOleDbConnectionTests
{
private static SyncConnectionOptions CreateOptions() => new()
{
Server = "127.0.0.1",
Port = 4900,
DatabaseName = "BASE_SYNC",
Login = "susana",
Password = "secret",
FilePasswords = new SyncFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = null
}
};
[Fact]
public void CreateForSync_MapsAllFields()
{
var options = CreateOptions();
var result = HfSqlConnectionOptionsFactory.CreateForSync(options);
Assert.Equal("127.0.0.1", result.Server);
Assert.Equal(4900, result.Port);
Assert.Equal("BASE_SYNC", result.Database);
Assert.Equal("susana", result.User);
Assert.Equal("secret", result.Credential);
Assert.Equal("STD_PASSWORD", result.FilePasswords.ProtectionStd);
Assert.Null(result.FilePasswords.ProtectionPlus);
}
[Fact]
public void CreateForSync_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlConnectionOptionsFactory.CreateForSync(null!));
}
[Fact]
public async Task OpenAsync_OpensConnection()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
}
[Fact]
public async Task OpenAsync_Twice_DoesNotReopen()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.OpenAsync();
Assert.Equal(HfsqlConnectionState.Open, connection.State);
Assert.Equal(1, factory.OpenCount);
}
[Fact]
public async Task CloseAsync_ClosesConnection()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
await connection.CloseAsync();
Assert.Equal(HfsqlConnectionState.Closed, connection.State);
}
[Fact]
public async Task QueryAsync_ReturnsRows()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var rows = await connection.QueryAsync("SELECT * FROM TOSRV");
Assert.Single(rows);
Assert.Equal("TS-001", rows[0]["TS_JSON"]);
}
[Fact]
public async Task ExecuteScalarAsync_ReturnsValue()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await connection.OpenAsync();
var value = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM TOSRV");
Assert.Equal(1L, value);
}
[Fact]
public async Task QueryAsync_NotOpen_Throws()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
await Assert.ThrowsAsync<InvalidOperationException>(
() => connection.QueryAsync("SELECT * FROM TOSRV"));
}
[Fact]
public async Task Options_ExposesConnectionName()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
Assert.Equal(SyncConnectionOptions.ConnectionName, connection.Options.ConnectionName);
Assert.Equal("BASE_SYNC", connection.Options.DatabaseName);
Assert.Equal("susana", connection.Options.Login);
}
[Fact]
public async Task Options_DoesNotExposePassword()
{
var factory = new FakeFactory();
await using var connection = new SyncOleDbConnection(CreateOptions(), factory);
var text = connection.Options.ToString();
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("STD_PASSWORD", text, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Fabrique simulée qui retourne une connexion factice sans serveur HFSQL.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
public int OpenCount { get; private set; }
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
return new FakeConnection(() => OpenCount++);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly Action _onOpen;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(Action onOpen)
{
_onOpen = onOpen;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "BASE_SYNC";
public override string DataSource => "127.0.0.1:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open()
{
_onOpen();
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand();
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => 1L;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => new FakeReader();
}
private sealed class FakeReader : DbDataReader
{
private bool _read;
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => GetValue(ordinal);
public override object this[string name] => GetValue(GetOrdinal(name));
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => typeof(string);
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => "TS_JSON";
public override int GetOrdinal(string name) => name == "TS_JSON" ? 0 : throw new IndexOutOfRangeException();
public override string GetString(int ordinal) => "TS-001";
public override object GetValue(int ordinal) => "TS-001";
public override int GetValues(object[] values)
{
values[0] = "TS-001";
return 1;
}
public override bool IsDBNull(int ordinal) => false;
public override bool NextResult() => false;
public override bool Read()
{
if (_read)
return false;
_read = true;
return true;
}
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override System.Collections.IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}