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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<EspoEntityRef> RecentEntities { get; set; } = new List<EspoEntityRef>();
|
||||
|
||||
[JsonPropertyName("watchedFolders")]
|
||||
public List<WatchedFolder> WatchedFolders { get; set; } = new List<WatchedFolder>();
|
||||
}
|
||||
}
|
||||
@@ -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<AppSettings>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<string> ContactsIds { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> To { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("cc")]
|
||||
public List<string> Cc { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("bcc")]
|
||||
public List<string> Bcc { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("replyTo")]
|
||||
public List<string> ReplyTo { get; set; } = new List<string>();
|
||||
|
||||
[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<EspoAttachment> Attachments { get; set; } = new List<EspoAttachment>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<EspoEntityRef> List { get; set; } = new List<EspoEntityRef>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,14 @@
|
||||
<Product>OutlookAddin</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Security" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="Polly" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
|
||||
@@ -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<StoredCredentials>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HttpResponseMessage> _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<HttpResponseMessage> BuildPipeline(ILogger logger)
|
||||
{
|
||||
var transientPredicate = new PredicateBuilder<HttpResponseMessage>()
|
||||
.Handle<HttpRequestException>()
|
||||
.Handle<TimeoutRejectedException>()
|
||||
.HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == HttpStatusCode.RequestTimeout);
|
||||
|
||||
return new ResiliencePipelineBuilder<HttpResponseMessage>()
|
||||
.AddTimeout(TimeSpan.FromSeconds(15))
|
||||
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
|
||||
{
|
||||
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<HttpResponseMessage>
|
||||
{
|
||||
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<FileEmailResponse> 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<FileEmailResponse>(body, JsonOptions)
|
||||
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty FileEmailResponse from EspoCRM");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SearchResult> 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<SearchResult>(path, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<SearchResult> 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<SearchResult>(path, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<CaseEntity> GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default)
|
||||
=> GetEntityAsync<CaseEntity>("Case", id, select, cancellationToken);
|
||||
|
||||
public Task<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default)
|
||||
=> GetEntityAsync<AccountEntity>("Account", id, select, cancellationToken);
|
||||
|
||||
public Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default)
|
||||
=> GetEntityAsync<ContactEntity>("Contact", id, select, cancellationToken);
|
||||
|
||||
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
||||
|
||||
private Task<TResult> GetEntityAsync<TResult>(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<TResult>(path, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TResult> GetJsonAsync<TResult>(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<TResult>(body, JsonOptions)
|
||||
?? throw new EspoCrmHttpException(response.StatusCode, body, $"Empty {typeof(TResult).Name} from EspoCRM");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> 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<string> 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<FileEmailResponse>(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) + "...");
|
||||
}
|
||||
}
|
||||
@@ -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<FileEmailResponse> FileMailAsync(FileEmailRequest request, CancellationToken cancellationToken = default);
|
||||
Task<SearchResult> GlobalSearchAsync(string query, CancellationToken cancellationToken = default);
|
||||
Task<SearchResult> EmailAddressSearchAsync(string emailAddress, string? entityType = null, CancellationToken cancellationToken = default);
|
||||
Task<CaseEntity> GetCaseAsync(string id, string? select = null, CancellationToken cancellationToken = default);
|
||||
Task<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default);
|
||||
Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default);
|
||||
Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>(() => store.Save("", "k"));
|
||||
Assert.Throws<ArgumentException>(() => store.Save("u", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HttpMessageHandler> handler) BuildClient(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
{
|
||||
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
|
||||
handler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
|
||||
.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 = "<mid@server>"
|
||||
});
|
||||
});
|
||||
|
||||
var result = await client.FileMailAsync(new FileEmailRequest
|
||||
{
|
||||
Subject = "Hi",
|
||||
From = "a@b.c",
|
||||
ParentType = "Case",
|
||||
ParentId = "case-1",
|
||||
MessageId = "<mid@server>"
|
||||
});
|
||||
|
||||
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 = "<mid@server>"
|
||||
}));
|
||||
|
||||
var result = await client.FileMailAsync(new FileEmailRequest
|
||||
{
|
||||
ParentType = "Case",
|
||||
ParentId = "case-1",
|
||||
MessageId = "<mid@server>"
|
||||
});
|
||||
|
||||
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 = "<mid@server>"
|
||||
});
|
||||
|
||||
result.Created.Should().BeFalse();
|
||||
result.ParentType.Should().Be("Case");
|
||||
result.ParentId.Should().Be("case-1");
|
||||
result.MessageId.Should().Be("<mid@server>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Any401_ThrowsAuthorizationException()
|
||||
{
|
||||
var (client, _) = BuildClient(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = new StringContent("nope")
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<EspoCrmAuthorizationException>(() =>
|
||||
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<EspoCrmHttpException>())
|
||||
.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<HttpMessageHandler>().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<ArgumentException>();
|
||||
a2.Should().Throw<ArgumentException>();
|
||||
a3.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user