Premiere commit - Omika
This commit is contained in:
@@ -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() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user