From f563dd17dd4bb8ad5ddfb833045af662dc138ec3 Mon Sep 17 00:00:00 2001 From: PointStar Date: Mon, 11 May 2026 13:27:05 +0300 Subject: [PATCH] feat(core): implement EspoCrmClient, DPAPI store, settings, logger (Task #2) - EspoCrmClient: typed REST wrapper with Polly v8 pipeline (Timeout 15s -> Retry 3 exp+jitter -> CircuitBreaker 0.5/5/30s). Auth via Espo-Authorization header (base64 username:apiKey). Treats 409 on /MailRouter/file as success (already-filed dedup), 401/403 raise EspoCrmAuthorizationException for re-auth UI. - DTOs in Models/ matching MailRouter v1.2.0 contract from PROJECT-BRIEFING.md (FileEmailRequest now includes replyTo + isHtml, attachments use contentType+contentBase64). - DpapiCredentialStore (Security/): ProtectedData.Protect with CurrentUser scope, SHA256("MarcusLaw.OutlookAddin.v1") entropy. - SettingsManager + AppSettings + WatchedFolder (Configuration/): System.Text.Json with UnsafeRelaxedJsonEscaping for Hebrew strings, UTF-8 without BOM, recovers to defaults on missing/corrupt file. - LoggerFactory (Logging/): Serilog daily-rolling File sink with 14-day retention. 24 xUnit tests covering happy/4xx/5xx/auth/Hebrew round-trips all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Configuration/AppSettings.cs | 21 ++ .../Configuration/SettingsManager.cs | 77 ++++++ .../Configuration/WatchedFolder.cs | 31 +++ .../Logging/LoggerFactory.cs | 49 ++++ src/OutlookAddin.Core/Models/AccountEntity.cs | 16 ++ src/OutlookAddin.Core/Models/CaseEntity.cs | 30 +++ src/OutlookAddin.Core/Models/ContactEntity.cs | 22 ++ .../Models/EmailAddressMatch.cs | 19 ++ .../Models/EspoAttachment.cs | 16 ++ src/OutlookAddin.Core/Models/EspoEntityRef.cs | 16 ++ .../Models/FileEmailRequest.cs | 48 ++++ .../Models/FileEmailResponse.cs | 22 ++ src/OutlookAddin.Core/Models/SearchResult.cs | 14 + src/OutlookAddin.Core/Models/UserInfo.cs | 19 ++ .../OutlookAddin.Core.csproj | 6 + .../Security/DpapiCredentialStore.cs | 103 ++++++++ .../Services/EspoCrmAuthorizationException.cs | 26 ++ .../Services/EspoCrmClient.cs | 243 ++++++++++++++++++ .../Services/IEspoCrmClient.cs | 17 ++ .../Configuration/SettingsManagerTests.cs | 136 ++++++++++ .../Logging/LoggerFactoryTests.cs | 49 ++++ .../Security/DpapiCredentialStoreTests.cs | 105 ++++++++ .../Services/EspoCrmClientTests.cs | 230 +++++++++++++++++ 23 files changed, 1315 insertions(+) create mode 100644 src/OutlookAddin.Core/Configuration/AppSettings.cs create mode 100644 src/OutlookAddin.Core/Configuration/SettingsManager.cs create mode 100644 src/OutlookAddin.Core/Configuration/WatchedFolder.cs create mode 100644 src/OutlookAddin.Core/Logging/LoggerFactory.cs create mode 100644 src/OutlookAddin.Core/Models/AccountEntity.cs create mode 100644 src/OutlookAddin.Core/Models/CaseEntity.cs create mode 100644 src/OutlookAddin.Core/Models/ContactEntity.cs create mode 100644 src/OutlookAddin.Core/Models/EmailAddressMatch.cs create mode 100644 src/OutlookAddin.Core/Models/EspoAttachment.cs create mode 100644 src/OutlookAddin.Core/Models/EspoEntityRef.cs create mode 100644 src/OutlookAddin.Core/Models/FileEmailRequest.cs create mode 100644 src/OutlookAddin.Core/Models/FileEmailResponse.cs create mode 100644 src/OutlookAddin.Core/Models/SearchResult.cs create mode 100644 src/OutlookAddin.Core/Models/UserInfo.cs create mode 100644 src/OutlookAddin.Core/Security/DpapiCredentialStore.cs create mode 100644 src/OutlookAddin.Core/Services/EspoCrmAuthorizationException.cs create mode 100644 src/OutlookAddin.Core/Services/EspoCrmClient.cs create mode 100644 src/OutlookAddin.Core/Services/IEspoCrmClient.cs create mode 100644 src/OutlookAddin.Tests/Configuration/SettingsManagerTests.cs create mode 100644 src/OutlookAddin.Tests/Logging/LoggerFactoryTests.cs create mode 100644 src/OutlookAddin.Tests/Security/DpapiCredentialStoreTests.cs create mode 100644 src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs diff --git a/src/OutlookAddin.Core/Configuration/AppSettings.cs b/src/OutlookAddin.Core/Configuration/AppSettings.cs new file mode 100644 index 0000000..03bfe9b --- /dev/null +++ b/src/OutlookAddin.Core/Configuration/AppSettings.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MarcusLaw.OutlookAddin.Core.Models; + +namespace MarcusLaw.OutlookAddin.Core.Configuration +{ + public sealed class AppSettings + { + [JsonPropertyName("espoCrmUrl")] + public string EspoCrmUrl { get; set; } = "https://crm.prod.marcus-law.co.il"; + + [JsonPropertyName("locale")] + public string Locale { get; set; } = "he-IL"; + + [JsonPropertyName("recentEntities")] + public List RecentEntities { get; set; } = new List(); + + [JsonPropertyName("watchedFolders")] + public List WatchedFolders { get; set; } = new List(); + } +} diff --git a/src/OutlookAddin.Core/Configuration/SettingsManager.cs b/src/OutlookAddin.Core/Configuration/SettingsManager.cs new file mode 100644 index 0000000..1b0f209 --- /dev/null +++ b/src/OutlookAddin.Core/Configuration/SettingsManager.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Configuration +{ + public sealed class SettingsManager + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly string _filePath; + + public SettingsManager() + : this(DefaultPath()) + { + } + + public SettingsManager(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("filePath required", nameof(filePath)); + _filePath = filePath; + } + + public string FilePath => _filePath; + + public AppSettings Load() + { + if (!File.Exists(_filePath)) + { + return new AppSettings(); + } + + var json = File.ReadAllText(_filePath, Encoding.UTF8); + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + } + catch (JsonException) + { + return new AppSettings(); + } + } + + public void Save(AppSettings settings) + { + if (settings == null) throw new ArgumentNullException(nameof(settings)); + + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + var json = JsonSerializer.Serialize(settings, JsonOptions); + File.WriteAllText(_filePath, json, new UTF8Encoding(false)); + } + + private static string DefaultPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MarcusLaw", + "OutlookAddin", + "settings.json"); + } + } +} diff --git a/src/OutlookAddin.Core/Configuration/WatchedFolder.cs b/src/OutlookAddin.Core/Configuration/WatchedFolder.cs new file mode 100644 index 0000000..20e8c6c --- /dev/null +++ b/src/OutlookAddin.Core/Configuration/WatchedFolder.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Configuration +{ + public enum WatchMode + { + AutoFile = 0, + NotifyOnly = 1 + } + + public sealed class WatchedFolder + { + [JsonPropertyName("storeId")] + public string StoreId { get; set; } = string.Empty; + + [JsonPropertyName("folderPath")] + public string FolderPath { get; set; } = string.Empty; + + [JsonPropertyName("defaultParentId")] + public string? DefaultParentId { get; set; } + + [JsonPropertyName("defaultParentType")] + public string? DefaultParentType { get; set; } + + [JsonPropertyName("mode")] + public WatchMode Mode { get; set; } = WatchMode.NotifyOnly; + + [JsonPropertyName("confidenceThreshold")] + public double ConfidenceThreshold { get; set; } = 0.8; + } +} diff --git a/src/OutlookAddin.Core/Logging/LoggerFactory.cs b/src/OutlookAddin.Core/Logging/LoggerFactory.cs new file mode 100644 index 0000000..c27edc6 --- /dev/null +++ b/src/OutlookAddin.Core/Logging/LoggerFactory.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Serilog; +using Serilog.Events; + +namespace MarcusLaw.OutlookAddin.Core.Logging +{ + public static class LoggerFactory + { + private const string OutputTemplate = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"; + + public static ILogger Init() + => Init(DefaultLogDirectory()); + + public static ILogger Init(string logDirectory) + { + if (string.IsNullOrWhiteSpace(logDirectory)) throw new ArgumentException("logDirectory required", nameof(logDirectory)); + + Directory.CreateDirectory(logDirectory); + + var path = Path.Combine(logDirectory, "addin-.log"); + + var logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + path, + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 14, + outputTemplate: OutputTemplate, + shared: true) + .CreateLogger(); + + Log.Logger = logger; + return logger; + } + + private static string DefaultLogDirectory() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MarcusLaw", + "OutlookAddin", + "logs"); + } + } +} diff --git a/src/OutlookAddin.Core/Models/AccountEntity.cs b/src/OutlookAddin.Core/Models/AccountEntity.cs new file mode 100644 index 0000000..305f211 --- /dev/null +++ b/src/OutlookAddin.Core/Models/AccountEntity.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class AccountEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("emailAddress")] + public string? EmailAddress { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/CaseEntity.cs b/src/OutlookAddin.Core/Models/CaseEntity.cs new file mode 100644 index 0000000..220f1f2 --- /dev/null +++ b/src/OutlookAddin.Core/Models/CaseEntity.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class CaseEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("accountId")] + public string? AccountId { get; set; } + + [JsonPropertyName("contactsIds")] + public List ContactsIds { get; set; } = new List(); + + [JsonPropertyName("createdAt")] + public DateTimeOffset? CreatedAt { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/ContactEntity.cs b/src/OutlookAddin.Core/Models/ContactEntity.cs new file mode 100644 index 0000000..9cb2d84 --- /dev/null +++ b/src/OutlookAddin.Core/Models/ContactEntity.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class ContactEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("emailAddress")] + public string? EmailAddress { get; set; } + + [JsonPropertyName("accountId")] + public string? AccountId { get; set; } + + [JsonPropertyName("accountName")] + public string? AccountName { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/EmailAddressMatch.cs b/src/OutlookAddin.Core/Models/EmailAddressMatch.cs new file mode 100644 index 0000000..e41b5fc --- /dev/null +++ b/src/OutlookAddin.Core/Models/EmailAddressMatch.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class EmailAddressMatch + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("entityType")] + public string EntityType { get; set; } = string.Empty; + + [JsonPropertyName("emailAddress")] + public string? EmailAddress { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/EspoAttachment.cs b/src/OutlookAddin.Core/Models/EspoAttachment.cs new file mode 100644 index 0000000..8ce2779 --- /dev/null +++ b/src/OutlookAddin.Core/Models/EspoAttachment.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class EspoAttachment + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("contentBase64")] + public string ContentBase64 { get; set; } = string.Empty; + } +} diff --git a/src/OutlookAddin.Core/Models/EspoEntityRef.cs b/src/OutlookAddin.Core/Models/EspoEntityRef.cs new file mode 100644 index 0000000..5c32283 --- /dev/null +++ b/src/OutlookAddin.Core/Models/EspoEntityRef.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class EspoEntityRef + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("entityType")] + public string EntityType { get; set; } = string.Empty; + } +} diff --git a/src/OutlookAddin.Core/Models/FileEmailRequest.cs b/src/OutlookAddin.Core/Models/FileEmailRequest.cs new file mode 100644 index 0000000..1b899f1 --- /dev/null +++ b/src/OutlookAddin.Core/Models/FileEmailRequest.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class FileEmailRequest + { + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("to")] + public List To { get; set; } = new List(); + + [JsonPropertyName("cc")] + public List Cc { get; set; } = new List(); + + [JsonPropertyName("bcc")] + public List Bcc { get; set; } = new List(); + + [JsonPropertyName("replyTo")] + public List ReplyTo { get; set; } = new List(); + + [JsonPropertyName("body")] + public string Body { get; set; } = string.Empty; + + [JsonPropertyName("isHtml")] + public bool IsHtml { get; set; } + + [JsonPropertyName("dateSent")] + public DateTimeOffset DateSent { get; set; } + + [JsonPropertyName("parentType")] + public string ParentType { get; set; } = string.Empty; + + [JsonPropertyName("parentId")] + public string ParentId { get; set; } = string.Empty; + + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = new List(); + } +} diff --git a/src/OutlookAddin.Core/Models/FileEmailResponse.cs b/src/OutlookAddin.Core/Models/FileEmailResponse.cs new file mode 100644 index 0000000..5251888 --- /dev/null +++ b/src/OutlookAddin.Core/Models/FileEmailResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class FileEmailResponse + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("created")] + public bool Created { get; set; } + + [JsonPropertyName("parentType")] + public string ParentType { get; set; } = string.Empty; + + [JsonPropertyName("parentId")] + public string ParentId { get; set; } = string.Empty; + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + } +} diff --git a/src/OutlookAddin.Core/Models/SearchResult.cs b/src/OutlookAddin.Core/Models/SearchResult.cs new file mode 100644 index 0000000..2e8f389 --- /dev/null +++ b/src/OutlookAddin.Core/Models/SearchResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class SearchResult + { + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("list")] + public List List { get; set; } = new List(); + } +} diff --git a/src/OutlookAddin.Core/Models/UserInfo.cs b/src/OutlookAddin.Core/Models/UserInfo.cs new file mode 100644 index 0000000..6c20d48 --- /dev/null +++ b/src/OutlookAddin.Core/Models/UserInfo.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Models +{ + public sealed class UserInfo + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("userName")] + public string? UserName { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("emailAddress")] + public string? EmailAddress { get; set; } + } +} diff --git a/src/OutlookAddin.Core/OutlookAddin.Core.csproj b/src/OutlookAddin.Core/OutlookAddin.Core.csproj index ddf20bc..fd54901 100644 --- a/src/OutlookAddin.Core/OutlookAddin.Core.csproj +++ b/src/OutlookAddin.Core/OutlookAddin.Core.csproj @@ -12,8 +12,14 @@ OutlookAddin + + + + + + diff --git a/src/OutlookAddin.Core/Security/DpapiCredentialStore.cs b/src/OutlookAddin.Core/Security/DpapiCredentialStore.cs new file mode 100644 index 0000000..e837622 --- /dev/null +++ b/src/OutlookAddin.Core/Security/DpapiCredentialStore.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Security +{ + public sealed class StoredCredentials + { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } = string.Empty; + } + + public sealed class DpapiCredentialStore + { + private const string EntropySeed = "MarcusLaw.OutlookAddin.v1"; + private static readonly byte[] Entropy = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(EntropySeed)); + + private readonly string _filePath; + + public DpapiCredentialStore() + : this(DefaultPath()) + { + } + + public DpapiCredentialStore(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("filePath required", nameof(filePath)); + _filePath = filePath; + } + + public string FilePath => _filePath; + + public void Save(string username, string apiKey) + { + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username required", nameof(username)); + if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentException("apiKey required", nameof(apiKey)); + + var payload = JsonSerializer.SerializeToUtf8Bytes(new StoredCredentials + { + Username = username, + ApiKey = apiKey + }); + + var encrypted = ProtectedData.Protect(payload, Entropy, DataProtectionScope.CurrentUser); + + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllBytes(_filePath, encrypted); + } + + public StoredCredentials? Load() + { + if (!File.Exists(_filePath)) return null; + + var encrypted = File.ReadAllBytes(_filePath); + byte[] decrypted; + try + { + decrypted = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); + } + catch (CryptographicException) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(decrypted); + } + catch (JsonException) + { + return null; + } + } + + public void Delete() + { + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + + private static string DefaultPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MarcusLaw", + "OutlookAddin", + "creds.dat"); + } + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmAuthorizationException.cs b/src/OutlookAddin.Core/Services/EspoCrmAuthorizationException.cs new file mode 100644 index 0000000..a91be0b --- /dev/null +++ b/src/OutlookAddin.Core/Services/EspoCrmAuthorizationException.cs @@ -0,0 +1,26 @@ +using System; +using System.Net; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public sealed class EspoCrmAuthorizationException : Exception + { + public EspoCrmAuthorizationException(string message) + : base(message) + { + } + } + + public sealed class EspoCrmHttpException : Exception + { + public HttpStatusCode StatusCode { get; } + public string? ResponseBody { get; } + + public EspoCrmHttpException(HttpStatusCode statusCode, string? responseBody, string message) + : base(message) + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs new file mode 100644 index 0000000..3c7482c --- /dev/null +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -0,0 +1,243 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; +using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; +using Polly.Timeout; +using Serilog; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public sealed class EspoCrmClient : IEspoCrmClient + { + private const string ApiPrefix = "api/v1/"; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ResiliencePipeline _pipeline; + + public EspoCrmClient(HttpClient httpClient, string baseUrl, string username, string apiKey, ILogger logger) + { + if (httpClient == null) throw new ArgumentNullException(nameof(httpClient)); + if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("baseUrl required", nameof(baseUrl)); + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username required", nameof(username)); + if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentException("apiKey required", nameof(apiKey)); + + _httpClient = httpClient; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + var trimmed = baseUrl.TrimEnd('/') + "/"; + _httpClient.BaseAddress = new Uri(trimmed, UriKind.Absolute); + + var token = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + apiKey)); + _httpClient.DefaultRequestHeaders.Remove("Espo-Authorization"); + _httpClient.DefaultRequestHeaders.Add("Espo-Authorization", token); + _httpClient.DefaultRequestHeaders.Accept.Clear(); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + _pipeline = BuildPipeline(logger); + } + + private static ResiliencePipeline BuildPipeline(ILogger logger) + { + var transientPredicate = new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == HttpStatusCode.RequestTimeout); + + return new ResiliencePipelineBuilder() + .AddTimeout(TimeSpan.FromSeconds(15)) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromMilliseconds(500), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = transientPredicate, + OnRetry = args => + { + logger.Warning("EspoCRM transient failure, retry {Attempt} after {Delay}ms", + args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds); + return default; + } + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatio = 0.5, + MinimumThroughput = 5, + BreakDuration = TimeSpan.FromSeconds(30), + SamplingDuration = TimeSpan.FromSeconds(30), + ShouldHandle = transientPredicate, + OnOpened = args => + { + logger.Error("EspoCRM circuit breaker opened for {BreakDuration}s", + args.BreakDuration.TotalSeconds); + return default; + }, + OnClosed = args => + { + logger.Information("EspoCRM circuit breaker closed"); + return default; + } + }) + .Build(); + } + + public async Task FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + + var json = JsonSerializer.Serialize(request, JsonOptions); + var response = await ExecuteAsync(HttpMethod.Post, "MailRouter/file", json, cancellationToken).ConfigureAwait(false); + + try + { + if (response.StatusCode == HttpStatusCode.Conflict) + { + _logger.Information("EspoCRM /MailRouter/file returned 409 (already filed) for messageId={MessageId}", request.MessageId); + var conflictBody = await ReadBodyAsync(response).ConfigureAwait(false); + return DeserializeOrEmpty(conflictBody, request); + } + + await EnsureSuccessAsync(response).ConfigureAwait(false); + var body = await ReadBodyAsync(response).ConfigureAwait(false); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty FileEmailResponse from EspoCRM"); + } + finally + { + response.Dispose(); + } + } + + public Task GlobalSearchAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) throw new ArgumentException("query required", nameof(query)); + var path = "GlobalSearch?q=" + Uri.EscapeDataString(query); + return GetJsonAsync(path, cancellationToken); + } + + public Task EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(emailAddress)) throw new ArgumentException("emailAddress required", nameof(emailAddress)); + var path = "EmailAddress/search?q=" + Uri.EscapeDataString(emailAddress); + if (!string.IsNullOrWhiteSpace(entityType)) + { + path += "&entityType=" + Uri.EscapeDataString(entityType); + } + return GetJsonAsync(path, cancellationToken); + } + + public Task GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default) + => GetEntityAsync("Case", id, select, cancellationToken); + + public Task GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default) + => GetEntityAsync("Account", id, select, cancellationToken); + + public Task GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default) + => GetEntityAsync("Contact", id, select, cancellationToken); + + public Task TestConnectionAsync(CancellationToken cancellationToken = default) + => GetJsonAsync("App/user", cancellationToken); + + private Task GetEntityAsync(string entityType, string id, string? select, CancellationToken cancellationToken) + where TResult : class + { + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("id required", nameof(id)); + var path = entityType + "/" + Uri.EscapeDataString(id); + if (!string.IsNullOrWhiteSpace(select)) + { + path += "?select=" + Uri.EscapeDataString(select); + } + return GetJsonAsync(path, cancellationToken); + } + + private async Task GetJsonAsync(string relativePath, CancellationToken cancellationToken) + where TResult : class + { + var response = await ExecuteAsync(HttpMethod.Get, relativePath, null, cancellationToken).ConfigureAwait(false); + try + { + await EnsureSuccessAsync(response).ConfigureAwait(false); + var body = await ReadBodyAsync(response).ConfigureAwait(false); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new EspoCrmHttpException(response.StatusCode, body, $"Empty {typeof(TResult).Name} from EspoCRM"); + } + finally + { + response.Dispose(); + } + } + + private async Task ExecuteAsync(HttpMethod method, string relativePath, string? jsonBody, CancellationToken cancellationToken) + { + return await _pipeline.ExecuteAsync(async ct => + { + var request = new HttpRequestMessage(method, ApiPrefix + relativePath); + if (jsonBody != null) + { + request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + } + return await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) return; + + var body = await ReadBodyAsync(response).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + throw new EspoCrmAuthorizationException( + $"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key."); + } + + throw new EspoCrmHttpException(response.StatusCode, body, + $"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}: {Truncate(body, 500)}"); + } + + private static async Task ReadBodyAsync(HttpResponseMessage response) + { + if (response.Content == null) return string.Empty; + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + + private static FileEmailResponse DeserializeOrEmpty(string body, FileEmailRequest request) + { + if (!string.IsNullOrWhiteSpace(body)) + { + try + { + var parsed = JsonSerializer.Deserialize(body, JsonOptions); + if (parsed != null) return parsed; + } + catch (JsonException) { } + } + return new FileEmailResponse + { + Created = false, + ParentType = request.ParentType, + ParentId = request.ParentId, + MessageId = request.MessageId + }; + } + + private static string Truncate(string s, int max) + => string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s.Substring(0, max) + "..."); + } +} diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs new file mode 100644 index 0000000..82e855e --- /dev/null +++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using MarcusLaw.OutlookAddin.Core.Models; + +namespace MarcusLaw.OutlookAddin.Core.Services +{ + public interface IEspoCrmClient + { + Task FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default); + Task GlobalSearchAsync(string query, CancellationToken cancellationToken = default); + Task EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default); + Task GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default); + Task GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default); + Task GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default); + Task TestConnectionAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/OutlookAddin.Tests/Configuration/SettingsManagerTests.cs b/src/OutlookAddin.Tests/Configuration/SettingsManagerTests.cs new file mode 100644 index 0000000..455910f --- /dev/null +++ b/src/OutlookAddin.Tests/Configuration/SettingsManagerTests.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.Text; +using FluentAssertions; +using MarcusLaw.OutlookAddin.Core.Configuration; +using MarcusLaw.OutlookAddin.Core.Models; +using Xunit; + +namespace MarcusLaw.OutlookAddin.Tests.Configuration +{ + public class SettingsManagerTests : IDisposable + { + private readonly string _tempDir; + + public SettingsManagerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "OutlookAddinSettingsTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + private SettingsManager NewManager() => + new SettingsManager(Path.Combine(_tempDir, "settings.json")); + + [Fact] + public void Load_ReturnsDefaults_WhenFileMissing() + { + var mgr = NewManager(); + + var settings = mgr.Load(); + + settings.Should().NotBeNull(); + settings.Locale.Should().Be("he-IL"); + settings.RecentEntities.Should().BeEmpty(); + settings.WatchedFolders.Should().BeEmpty(); + } + + [Fact] + public void SaveThenLoad_RoundTripsAllFields() + { + var mgr = NewManager(); + + var settings = new AppSettings + { + EspoCrmUrl = "https://crm.test/", + Locale = "en-US", + RecentEntities = + { + new EspoEntityRef { Id = "1", Name = "Smith Family", EntityType = "Account" } + }, + WatchedFolders = + { + new WatchedFolder + { + StoreId = "store-1", + FolderPath = @"\Inbox\Clients", + DefaultParentId = "p1", + DefaultParentType = "Case", + Mode = WatchMode.AutoFile, + ConfidenceThreshold = 0.9 + } + } + }; + + mgr.Save(settings); + var loaded = mgr.Load(); + + loaded.EspoCrmUrl.Should().Be("https://crm.test/"); + loaded.Locale.Should().Be("en-US"); + loaded.RecentEntities.Should().ContainSingle().Which.Name.Should().Be("Smith Family"); + loaded.WatchedFolders.Should().ContainSingle() + .Which.Should().BeEquivalentTo(settings.WatchedFolders[0]); + } + + [Fact] + public void SaveThenLoad_PreservesHebrewStrings() + { + var mgr = NewManager(); + + var settings = new AppSettings + { + Locale = "he-IL", + RecentEntities = + { + new EspoEntityRef { Id = "case-77", Name = "תיק משפחת כהן 2025-77", EntityType = "Case" } + } + }; + + mgr.Save(settings); + var loaded = mgr.Load(); + + loaded.RecentEntities[0].Name.Should().Be("תיק משפחת כהן 2025-77"); + } + + [Fact] + public void Save_WritesUtf8WithoutBom() + { + var mgr = NewManager(); + mgr.Save(new AppSettings { Locale = "he-IL" }); + + var bytes = File.ReadAllBytes(mgr.FilePath); + + bytes.Length.Should().BeGreaterThan(3); + (bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF).Should().BeFalse("settings.json should not have UTF-8 BOM"); + Encoding.UTF8.GetString(bytes).Should().Contain("\"he-IL\""); + } + + [Fact] + public void Load_ReturnsDefaults_WhenFileCorrupt() + { + var path = Path.Combine(_tempDir, "settings.json"); + File.WriteAllText(path, "{ this is not json"); + var mgr = new SettingsManager(path); + + var settings = mgr.Load(); + + settings.Should().NotBeNull(); + settings.Locale.Should().Be("he-IL"); + } + + [Fact] + public void Save_CreatesParentDirectory_WhenMissing() + { + var nested = Path.Combine(_tempDir, "a", "b", "settings.json"); + var mgr = new SettingsManager(nested); + + mgr.Save(new AppSettings()); + + File.Exists(nested).Should().BeTrue(); + } + } +} diff --git a/src/OutlookAddin.Tests/Logging/LoggerFactoryTests.cs b/src/OutlookAddin.Tests/Logging/LoggerFactoryTests.cs new file mode 100644 index 0000000..daaabd7 --- /dev/null +++ b/src/OutlookAddin.Tests/Logging/LoggerFactoryTests.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using FluentAssertions; +using MarcusLaw.OutlookAddin.Core.Logging; +using Xunit; + +namespace MarcusLaw.OutlookAddin.Tests.Logging +{ + public class LoggerFactoryTests : IDisposable + { + private readonly string _tempDir; + + public LoggerFactoryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "OutlookAddinLogTests-" + Guid.NewGuid().ToString("N")); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + [Fact] + public void Init_CreatesLogDirectoryAndWritesFile() + { + var logger = LoggerFactory.Init(_tempDir); + try + { + logger.Information("hello {Name}", "world"); + } + finally + { + Serilog.Log.CloseAndFlush(); + (logger as IDisposable)?.Dispose(); + } + + Directory.Exists(_tempDir).Should().BeTrue(); + var files = Directory.GetFiles(_tempDir, "addin-*.log"); + files.Should().NotBeEmpty(); + + using var fs = new FileStream(files[0], FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var sr = new StreamReader(fs); + var contents = sr.ReadToEnd(); + contents.Should().Contain("hello world"); + } + } +} diff --git a/src/OutlookAddin.Tests/Security/DpapiCredentialStoreTests.cs b/src/OutlookAddin.Tests/Security/DpapiCredentialStoreTests.cs new file mode 100644 index 0000000..308e8ef --- /dev/null +++ b/src/OutlookAddin.Tests/Security/DpapiCredentialStoreTests.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using FluentAssertions; +using MarcusLaw.OutlookAddin.Core.Security; +using Xunit; + +namespace MarcusLaw.OutlookAddin.Tests.Security +{ + public class DpapiCredentialStoreTests : IDisposable + { + private readonly string _tempDir; + + public DpapiCredentialStoreTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "OutlookAddinTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + private DpapiCredentialStore NewStore() => + new DpapiCredentialStore(Path.Combine(_tempDir, "creds.dat")); + + [Fact] + public void Save_ThenLoad_RoundTripsCredentials() + { + var store = NewStore(); + + store.Save("chaim@marcus-law.co.il", "abc123-secret"); + var loaded = store.Load(); + + loaded.Should().NotBeNull(); + loaded!.Username.Should().Be("chaim@marcus-law.co.il"); + loaded.ApiKey.Should().Be("abc123-secret"); + } + + [Fact] + public void Save_HebrewUsername_RoundTripsCorrectly() + { + var store = NewStore(); + + store.Save("חיים@marcus-law.co.il", "מפתח-סודי-עברי"); + var loaded = store.Load(); + + loaded!.Username.Should().Be("חיים@marcus-law.co.il"); + loaded.ApiKey.Should().Be("מפתח-סודי-עברי"); + } + + [Fact] + public void Load_ReturnsNull_WhenFileMissing() + { + var store = NewStore(); + + var loaded = store.Load(); + + loaded.Should().BeNull(); + } + + [Fact] + public void Save_CreatesParentDirectory_WhenMissing() + { + var nested = Path.Combine(_tempDir, "nested", "deep", "creds.dat"); + var store = new DpapiCredentialStore(nested); + + store.Save("u", "k"); + + File.Exists(nested).Should().BeTrue(); + } + + [Fact] + public void Save_WritesEncryptedBytes_NotPlaintext() + { + var store = NewStore(); + store.Save("u", "supersecretkey-distinctive-marker"); + + var bytes = File.ReadAllBytes(store.FilePath); + var asText = System.Text.Encoding.UTF8.GetString(bytes); + + asText.Should().NotContain("supersecretkey-distinctive-marker"); + } + + [Fact] + public void Delete_RemovesFile() + { + var store = NewStore(); + store.Save("u", "k"); + File.Exists(store.FilePath).Should().BeTrue(); + + store.Delete(); + + File.Exists(store.FilePath).Should().BeFalse(); + } + + [Fact] + public void Save_RejectsBlankInput() + { + var store = NewStore(); + Assert.Throws(() => store.Save("", "k")); + Assert.Throws(() => store.Save("u", "")); + } + } +} diff --git a/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs b/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs new file mode 100644 index 0000000..a3051fb --- /dev/null +++ b/src/OutlookAddin.Tests/Services/EspoCrmClientTests.cs @@ -0,0 +1,230 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MarcusLaw.OutlookAddin.Core.Models; +using MarcusLaw.OutlookAddin.Core.Services; +using Moq; +using Moq.Protected; +using Serilog; +using Xunit; + +namespace MarcusLaw.OutlookAddin.Tests.Services +{ + public class EspoCrmClientTests + { + private const string BaseUrl = "https://crm.test.example/"; + private const string Username = "lawyer"; + private const string ApiKey = "secret-key"; + + private static (EspoCrmClient client, Mock handler) BuildClient( + Func respond) + { + var handler = new Mock(MockBehavior.Strict); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage req, CancellationToken ct) => respond(req)); + + var httpClient = new HttpClient(handler.Object); + var logger = new LoggerConfiguration().CreateLogger(); + var client = new EspoCrmClient(httpClient, BaseUrl, Username, ApiKey, logger); + return (client, handler); + } + + private static HttpResponseMessage Json(HttpStatusCode code, object body) + { + return new HttpResponseMessage(code) + { + Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") + }; + } + + [Fact] + public async Task FileMailAsync_HappyPath_PostsAndReturnsResponse() + { + var (client, handler) = BuildClient(req => + { + req.Method.Should().Be(HttpMethod.Post); + req.RequestUri!.AbsoluteUri.Should().Be("https://crm.test.example/api/v1/MailRouter/file"); + return Json(HttpStatusCode.OK, new FileEmailResponse + { + Id = "abc", + Created = true, + ParentType = "Case", + ParentId = "case-1", + MessageId = "" + }); + }); + + var result = await client.FileMailAsync(new FileEmailRequest + { + Subject = "Hi", + From = "a@b.c", + ParentType = "Case", + ParentId = "case-1", + MessageId = "" + }); + + result.Id.Should().Be("abc"); + result.Created.Should().BeTrue(); + result.ParentType.Should().Be("Case"); + } + + [Fact] + public async Task FileMailAsync_AddsAuthHeader_AsBase64UsernameColonApiKey() + { + HttpRequestMessage? captured = null; + var (client, _) = BuildClient(req => + { + captured = req; + return Json(HttpStatusCode.OK, new FileEmailResponse { Id = "x", Created = true }); + }); + + await client.FileMailAsync(new FileEmailRequest { ParentType = "Case", ParentId = "c1" }); + + var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes(Username + ":" + ApiKey)); + captured!.Headers.GetValues("Espo-Authorization").Should().ContainSingle().Which.Should().Be(expected); + } + + [Fact] + public async Task FileMailAsync_409Conflict_ReturnsNotCreatedWithoutThrowing() + { + var (client, _) = BuildClient(_ => Json(HttpStatusCode.Conflict, new FileEmailResponse + { + Id = "existing", + Created = false, + ParentType = "Case", + ParentId = "case-1", + MessageId = "" + })); + + var result = await client.FileMailAsync(new FileEmailRequest + { + ParentType = "Case", + ParentId = "case-1", + MessageId = "" + }); + + result.Id.Should().Be("existing"); + result.Created.Should().BeFalse(); + } + + [Fact] + public async Task FileMailAsync_409WithEmptyBody_ReturnsSyntheticResponse() + { + var (client, _) = BuildClient(_ => new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "application/json") + }); + + var result = await client.FileMailAsync(new FileEmailRequest + { + ParentType = "Case", + ParentId = "case-1", + MessageId = "" + }); + + result.Created.Should().BeFalse(); + result.ParentType.Should().Be("Case"); + result.ParentId.Should().Be("case-1"); + result.MessageId.Should().Be(""); + } + + [Fact] + public async Task Any401_ThrowsAuthorizationException() + { + var (client, _) = BuildClient(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("nope") + }); + + await Assert.ThrowsAsync(() => + client.GetCaseAsync("case-1")); + } + + [Fact] + public async Task TransientServerError_RetriesThenSucceeds() + { + var attempts = 0; + var (client, handler) = BuildClient(_ => + { + attempts++; + if (attempts < 3) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("boom") + }; + } + return Json(HttpStatusCode.OK, new UserInfo { Id = "u1", UserName = "lawyer" }); + }); + + var result = await client.TestConnectionAsync(); + + result.Id.Should().Be("u1"); + attempts.Should().Be(3); + } + + [Fact] + public async Task PersistentServerError_ExhaustsRetriesThenThrows() + { + var (client, _) = BuildClient(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("permanent failure") + }); + + var act = () => client.GetCaseAsync("case-1"); + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + } + + [Fact] + public async Task GlobalSearch_BuildsUrlWithEncodedQuery() + { + HttpRequestMessage? captured = null; + var (client, _) = BuildClient(req => + { + captured = req; + return Json(HttpStatusCode.OK, new SearchResult { Total = 0 }); + }); + + await client.GlobalSearchAsync("Smith Family"); + + captured!.RequestUri!.AbsoluteUri.Should().Be("https://crm.test.example/api/v1/GlobalSearch?q=Smith%20Family"); + } + + [Fact] + public async Task EmailAddressSearch_AppendsEntityType() + { + HttpRequestMessage? captured = null; + var (client, _) = BuildClient(req => + { + captured = req; + return Json(HttpStatusCode.OK, new SearchResult { Total = 0 }); + }); + + await client.EmailAddressSearchAsync("a@b.c", "Contact"); + + captured!.RequestUri!.AbsoluteUri.Should().Contain("EmailAddress/search?q=a%40b.c&entityType=Contact"); + } + + [Fact] + public void Constructor_RejectsBlankCredentials() + { + var http = new HttpClient(new Mock().Object); + var logger = new LoggerConfiguration().CreateLogger(); + + Action a1 = () => new EspoCrmClient(http, BaseUrl, "", ApiKey, logger); + Action a2 = () => new EspoCrmClient(http, BaseUrl, Username, "", logger); + Action a3 = () => new EspoCrmClient(http, "", Username, ApiKey, logger); + + a1.Should().Throw(); + a2.Should().Throw(); + a3.Should().Throw(); + } + } +}