diff --git a/src/Crowdin.Api/AI/AiApiExecutor.cs b/src/Crowdin.Api/AI/AiApiExecutor.cs index 01dac608..265ffbe0 100644 --- a/src/Crowdin.Api/AI/AiApiExecutor.cs +++ b/src/Crowdin.Api/AI/AiApiExecutor.cs @@ -470,6 +470,79 @@ public async Task EditAiSettings(long? userId, IEnumerable(result.JsonObject); } + #endregion + + #region File Translations + + /// + /// AI File Translations. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task AiFileTranslations(long? userId, AiFileTranslationsRequest request) + { + string url = AddUserIdIfAvailable(userId, "/ai/file-translations"); + CrowdinApiResult result = await _apiClient.SendPostRequest(url, request); + return _jsonParser.ParseResponseObject(result.JsonObject); + } + + /// + /// Get File Translations Status. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task GetFileTranslationsStatus(long? userId, string jobIdentifier) + { + string url = AddUserIdIfAvailable(userId, $"/ai/file-translations/{jobIdentifier}"); + CrowdinApiResult result = await _apiClient.SendGetRequest(url); + return _jsonParser.ParseResponseObject(result.JsonObject); + } + + /// + /// Cancel File Translations. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task CancelFileTranslations(long? userId, string jobIdentifier) + { + string url = AddUserIdIfAvailable(userId, $"/ai/file-translations/{jobIdentifier}"); + HttpStatusCode statusCode = await _apiClient.SendDeleteRequest(url); + Utils.ThrowIfStatusNot204(statusCode, $"File Translation {jobIdentifier} removal failed"); + } + + /// + /// Download Translated File. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + public async Task DownloadTranslatedFile(long? userId, string jobIdentifier) + { + string url = AddUserIdIfAvailable(userId, $"/ai/file-translations/{jobIdentifier}/download"); + CrowdinApiResult result = await _apiClient.SendGetRequest(url); + return _jsonParser.ParseResponseObject(result.JsonObject); + } + + /// + /// Download Translated File Strings. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + [PublicAPI] + public async Task DownloadTranslatedFileStrings(long? userId, string jobIdentifier) + { + string url = AddUserIdIfAvailable(userId, $"/ai/file-translations/{jobIdentifier}/translations"); + CrowdinApiResult result = await _apiClient.SendGetRequest(url); + return _jsonParser.ParseResponseObject(result.JsonObject); + } + #endregion /// diff --git a/src/Crowdin.Api/AI/AiFileTranslationsRequest.cs b/src/Crowdin.Api/AI/AiFileTranslationsRequest.cs new file mode 100644 index 00000000..2256c431 --- /dev/null +++ b/src/Crowdin.Api/AI/AiFileTranslationsRequest.cs @@ -0,0 +1,50 @@ + +using System.Collections.Generic; +using Crowdin.Api.SourceFiles; +using JetBrains.Annotations; +using Newtonsoft.Json; + +#nullable enable + +namespace Crowdin.Api.AI +{ + [PublicAPI] + public class AiFileTranslationsRequest + { + [JsonProperty("storageId")] + public long StorageId { get; set; } + + [JsonProperty("sourceLanguageId")] + public string? SourceLanguageId { get; set; } + + [JsonProperty("targetLanguageId")] + public string TargetLanguageId { get; set; } = null!; + + [JsonProperty("type")] + public ProjectFileType? Type { get; set; } + + [JsonProperty("parserVersion")] + public int? ParserVersion { get; set; } + + [JsonProperty("tmIds")] + public ICollection? TmIds { get; set; } + + [JsonProperty("glossaryIds")] + public ICollection? GlossaryIds { get; set; } + + [JsonProperty("aiPromptId")] + public long? AiPromptId { get; set; } + + [JsonProperty("aiProviderId")] + public long? AiProviderId { get; set; } + + [JsonProperty("aiModelId")] + public string? AiModelId { get; set; } + + [JsonProperty("instructions")] + public ICollection? Instructions { get; set; } + + [JsonProperty("attachmentIds")] + public ICollection? AttachmentIds { get; set; } + } +} \ No newline at end of file diff --git a/src/Crowdin.Api/AI/AiFileTranslationsStage.cs b/src/Crowdin.Api/AI/AiFileTranslationsStage.cs new file mode 100644 index 00000000..d12ae1aa --- /dev/null +++ b/src/Crowdin.Api/AI/AiFileTranslationsStage.cs @@ -0,0 +1,25 @@ + +using System.ComponentModel; +using JetBrains.Annotations; + +namespace Crowdin.Api.AI +{ + [PublicAPI] + public enum AiFileTranslationsStage + { + [Description("start")] + Start, + + [Description("import")] + Import, + + [Description("translate")] + Translate, + + [Description("export")] + Export, + + [Description("done")] + Done + } +} \ No newline at end of file diff --git a/src/Crowdin.Api/AI/AiFileTranslationsStatus.cs b/src/Crowdin.Api/AI/AiFileTranslationsStatus.cs new file mode 100644 index 00000000..0fc5c2d8 --- /dev/null +++ b/src/Crowdin.Api/AI/AiFileTranslationsStatus.cs @@ -0,0 +1,76 @@ + +using System; +using Crowdin.Api.SourceFiles; +using JetBrains.Annotations; +using Newtonsoft.Json; + +#nullable enable + +namespace Crowdin.Api.AI +{ + [PublicAPI] + public class AiFileTranslationsStatus + { + [JsonProperty("identifier")] + public string Identifier { get; set; } = null!; + + [JsonProperty("status")] + public OperationStatus Status { get; set; } + + [JsonProperty("progress")] + public int Progress { get; set; } + + [JsonProperty("attributes")] + public AttributesObject Attributes { get; set; } = null!; + + [PublicAPI] + public class AttributesObject + { + [JsonProperty("stage")] + public AiFileTranslationsStage Stage { get; set; } + + [JsonProperty("error")] + public ErrorObject? Error { get; set; } + + [JsonProperty("downloadName")] + public string? DownloadName { get; set; } + + [JsonProperty("sourceLanguageId")] + public string? SourceLanguageId { get; set; } + + [JsonProperty("targetLanguageId")] + public string? TargetLanguageId { get; set; } + + [JsonProperty("originalFileName")] + public string? OriginalFileName { get; set; } + + [JsonProperty("detectedType")] + public ProjectFileType? DetectedType { get; set; } + + [JsonProperty("parserVersion")] + public int? ParserVersion { get; set; } + + [PublicAPI] + public class ErrorObject + { + [JsonProperty("stage")] + public AiFileTranslationsStage Stage { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } = null!; + } + } + + [JsonProperty("createdAt")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonProperty("updatedAt")] + public DateTimeOffset? UpdatedAt { get; set; } + + [JsonProperty("startedAt")] + public DateTimeOffset? StartedAt { get; set; } + + [JsonProperty("finishedAt")] + public DateTimeOffset? FinishedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Crowdin.Api/AI/IAiApiExecutor.cs b/src/Crowdin.Api/AI/IAiApiExecutor.cs index 054d88d5..cb147aa6 100644 --- a/src/Crowdin.Api/AI/IAiApiExecutor.cs +++ b/src/Crowdin.Api/AI/IAiApiExecutor.cs @@ -106,6 +106,50 @@ Task EditAiPrompt( Task EditAiSettings(long? userId, IEnumerable patches); + #endregion + + #region File Translations + + /// + /// AI File Translations. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + Task AiFileTranslations(long? userId, AiFileTranslationsRequest request); + + /// + /// Get File Translations Status. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + Task GetFileTranslationsStatus(long? userId, string jobIdentifier); + + /// + /// Cancel File Translations. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + Task CancelFileTranslations(long? userId, string jobIdentifier); + + /// + /// Download Translated File. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + Task DownloadTranslatedFile(long? userId, string jobIdentifier); + + /// + /// Download Translated File Strings. Documentation: + /// Crowdin File Based API + /// Crowdin String Based API + /// Crowdin Enterprise API + /// + Task DownloadTranslatedFileStrings(long? userId, string jobIdentifier); + #endregion Task CreateAiProxyChatCompletion( diff --git a/tests/Crowdin.Api.UnitTesting/CommonResponsesAssertUtils.cs b/tests/Crowdin.Api.UnitTesting/CommonResponsesAssertUtils.cs new file mode 100644 index 00000000..4a4709e1 --- /dev/null +++ b/tests/Crowdin.Api.UnitTesting/CommonResponsesAssertUtils.cs @@ -0,0 +1,17 @@ + +using System; +using Xunit; + +namespace Crowdin.Api.UnitTesting +{ + public static class CommonResponsesAssertUtils + { + public static void Assert_DownloadLink(DownloadLink? expectedDownloadLink) + { + ArgumentNullException.ThrowIfNull(expectedDownloadLink); + + Assert.Equal("https://test.com", expectedDownloadLink.Url); + Assert.Equal(DateTimeOffset.Parse("2019-09-20T10:31:21+00:00"), expectedDownloadLink.ExpireIn); + } + } +} \ No newline at end of file diff --git a/tests/Crowdin.Api.UnitTesting/Crowdin.Api.UnitTesting.csproj b/tests/Crowdin.Api.UnitTesting/Crowdin.Api.UnitTesting.csproj index 7008fe3d..30a98547 100644 --- a/tests/Crowdin.Api.UnitTesting/Crowdin.Api.UnitTesting.csproj +++ b/tests/Crowdin.Api.UnitTesting/Crowdin.Api.UnitTesting.csproj @@ -269,6 +269,14 @@ ResXFileCodeGenerator AI_TranslateStrings.Designer.cs + + ResXFileCodeGenerator + AI_FileTranslations.Designer.cs + + + ResXFileCodeGenerator + Common.Designer.cs + @@ -572,6 +580,16 @@ True AI_TranslateStrings.resx + + True + True + AI_FileTranslations.resx + + + True + True + Common.resx + diff --git a/tests/Crowdin.Api.UnitTesting/Resources/AI_FileTranslations.Designer.cs b/tests/Crowdin.Api.UnitTesting/Resources/AI_FileTranslations.Designer.cs new file mode 100644 index 00000000..8b7c6b0a --- /dev/null +++ b/tests/Crowdin.Api.UnitTesting/Resources/AI_FileTranslations.Designer.cs @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Crowdin.Api.UnitTesting.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class AI_FileTranslations { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal AI_FileTranslations() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Crowdin.Api.UnitTesting.Resources.AI_FileTranslations", typeof(AI_FileTranslations).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string AiFileTranslations_Request { + get { + return ResourceManager.GetString("AiFileTranslations_Request", resourceCulture); + } + } + + internal static string AiFileTranslations_Response { + get { + return ResourceManager.GetString("AiFileTranslations_Response", resourceCulture); + } + } + } +} diff --git a/tests/Crowdin.Api.UnitTesting/Resources/AI_FileTranslations.resx b/tests/Crowdin.Api.UnitTesting/Resources/AI_FileTranslations.resx new file mode 100644 index 00000000..ae7bc396 --- /dev/null +++ b/tests/Crowdin.Api.UnitTesting/Resources/AI_FileTranslations.resx @@ -0,0 +1,71 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + { + "storageId": 123, + "sourceLanguageId": "en", + "targetLanguageId": "uk", + "type": "xliff", + "parserVersion": 1, + "tmIds": [ + 123 + ], + "glossaryIds": [ + 456 + ], + "aiPromptId": 789, + "aiProviderId": 12, + "aiModelId": "gpt-4.1", + "instructions": [ + "Keep a formal tone" + ], + "attachmentIds": [ + 123 + ] +} + + + { + "data": { + "identifier": "50fb3506-4127-4ba8-8296-f97dc7e3e0c3", + "status": "finished", + "progress": 100, + "attributes": { + "stage": "translate", + "error": { + "stage": "import", + "message": "Failed to parse file" + }, + "downloadName": "file.pdf", + "sourceLanguageId": "en", + "targetLanguageId": "uk", + "originalFileName": "Sample_Chrome.json", + "detectedType": "chrome", + "parserVersion": 2 + }, + "createdAt": "2026-01-23T11:26:54+00:00", + "updatedAt": "2026-01-23T11:26:54+00:00", + "startedAt": "2026-01-23T11:26:54+00:00", + "finishedAt": "2026-01-23T11:26:54+00:00" + } +} + + \ No newline at end of file diff --git a/tests/Crowdin.Api.UnitTesting/Resources/Common.Designer.cs b/tests/Crowdin.Api.UnitTesting/Resources/Common.Designer.cs new file mode 100644 index 00000000..edeb4d33 --- /dev/null +++ b/tests/Crowdin.Api.UnitTesting/Resources/Common.Designer.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Crowdin.Api.UnitTesting.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Common { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Common() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Crowdin.Api.UnitTesting.Resources.Common", typeof(Common).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string Responses_DownloadLink { + get { + return ResourceManager.GetString("Responses_DownloadLink", resourceCulture); + } + } + } +} diff --git a/tests/Crowdin.Api.UnitTesting/Resources/Common.resx b/tests/Crowdin.Api.UnitTesting/Resources/Common.resx new file mode 100644 index 00000000..1eecec3e --- /dev/null +++ b/tests/Crowdin.Api.UnitTesting/Resources/Common.resx @@ -0,0 +1,29 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + { + "data": { + "url": "https://test.com", + "expireIn": "2019-09-20T10:31:21+00:00" + } +} + + \ No newline at end of file diff --git a/tests/Crowdin.Api.UnitTesting/Tests/AI/AiFileTranslationsTests.cs b/tests/Crowdin.Api.UnitTesting/Tests/AI/AiFileTranslationsTests.cs new file mode 100644 index 00000000..2acb0d15 --- /dev/null +++ b/tests/Crowdin.Api.UnitTesting/Tests/AI/AiFileTranslationsTests.cs @@ -0,0 +1,182 @@ + +using System; +using System.Net; +using System.Threading.Tasks; + +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +using Crowdin.Api.AI; +using Crowdin.Api.Core; +using Crowdin.Api.SourceFiles; +using Crowdin.Api.UnitTesting.Resources; + +namespace Crowdin.Api.UnitTesting.Tests.AI +{ + public class AiFileTranslationsTests + { + private static readonly JsonSerializerSettings JsonSettings = TestUtils.CreateJsonSerializerOptions(); + + [Fact] + public async Task AiFileTranslations() + { + const int userId = 1; + + var request = new AiFileTranslationsRequest + { + StorageId = 123, + SourceLanguageId = "en", + TargetLanguageId = "uk", + Type = ProjectFileType.Xliff, + ParserVersion = 1, + TmIds = [123], + GlossaryIds = [456], + AiPromptId = 789, + AiProviderId = 12, + AiModelId = "gpt-4.1", + Instructions = [ + "Keep a formal tone" + ], + AttachmentIds = [123] + }; + + string actualRequestJson = JsonConvert.SerializeObject(request, JsonSettings); + string expectedRequestJson = TestUtils.CompactJson(AI_FileTranslations.AiFileTranslations_Request); + Assert.Equal(expectedRequestJson, actualRequestJson); + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/users/{userId}/ai/file-translations"; + + mockClient + .Setup(client => client.SendPostRequest(url, request, null)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.Created, + JsonObject = JObject.Parse(AI_FileTranslations.AiFileTranslations_Response) + }); + + var executor = new AiApiExecutor(mockClient.Object); + AiFileTranslationsStatus? response = await executor.AiFileTranslations(userId, request); + Assert_AiFileTranslationsStatus(response); + } + + [Fact] + public async Task GetFileTranslationsStatus() + { + const int userId = 1; + const string jobIdentifier = "50fb3506-4127-4ba8-8296-f97dc7e3e0c3"; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/users/{userId}/ai/file-translations/{jobIdentifier}"; + + mockClient + .Setup(client => client.SendGetRequest(url, null)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(AI_FileTranslations.AiFileTranslations_Response) + }); + + var executor = new AiApiExecutor(mockClient.Object); + AiFileTranslationsStatus? response = await executor.GetFileTranslationsStatus(userId, jobIdentifier); + Assert_AiFileTranslationsStatus(response); + } + + [Fact] + public async Task CancelFileTranslations() + { + const string jobIdentifier = "50fb3506-4127-4ba8-8296-f97dc7e3e0c3"; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + const string url = $"/ai/file-translations/{jobIdentifier}"; + + mockClient + .Setup(client => client.SendDeleteRequest(url, null)) + .ReturnsAsync(HttpStatusCode.NoContent); + + var executor = new AiApiExecutor(mockClient.Object); + await executor.CancelFileTranslations(null, jobIdentifier); // Enterprise Mode + } + + [Fact] + public async Task DownloadTranslatedFile() + { + const int userId = 1; + const string jobIdentifier = "50fb3506-4127-4ba8-8296-f97dc7e3e0c3"; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/users/{userId}/ai/file-translations/{jobIdentifier}/download"; + + mockClient + .Setup(client => client.SendGetRequest(url, null)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(Common.Responses_DownloadLink) + }); + + var executor = new AiApiExecutor(mockClient.Object); + DownloadLink? response = await executor.DownloadTranslatedFile(userId, jobIdentifier); + CommonResponsesAssertUtils.Assert_DownloadLink(response); + } + + [Fact] + public async Task DownloadTranslatedFileStrings() + { + const int userId = 1; + const string jobIdentifier = "50fb3506-4127-4ba8-8296-f97dc7e3e0c3"; + + Mock mockClient = TestUtils.CreateMockClientWithDefaultParser(); + + var url = $"/users/{userId}/ai/file-translations/{jobIdentifier}/translations"; + + mockClient + .Setup(client => client.SendGetRequest(url, null)) + .ReturnsAsync(new CrowdinApiResult + { + StatusCode = HttpStatusCode.OK, + JsonObject = JObject.Parse(Common.Responses_DownloadLink) + }); + + var executor = new AiApiExecutor(mockClient.Object); + DownloadLink? response = await executor.DownloadTranslatedFileStrings(userId, jobIdentifier); + CommonResponsesAssertUtils.Assert_DownloadLink(response); + } + + private static void Assert_AiFileTranslationsStatus(AiFileTranslationsStatus? response) + { + ArgumentNullException.ThrowIfNull(response); + + Assert.Equal("50fb3506-4127-4ba8-8296-f97dc7e3e0c3", response.Identifier); + Assert.Equal(OperationStatus.Finished, response.Status); + Assert.Equal(100, response.Progress); + + AiFileTranslationsStatus.AttributesObject attributes = response.Attributes; + ArgumentNullException.ThrowIfNull(attributes); + Assert.Equal(AiFileTranslationsStage.Translate, attributes.Stage); + Assert.Equal("file.pdf", attributes.DownloadName); + Assert.Equal("en", attributes.SourceLanguageId); + Assert.Equal("uk", attributes.TargetLanguageId); + Assert.Equal("Sample_Chrome.json", attributes.OriginalFileName); + Assert.Equal(ProjectFileType.Chrome, attributes.DetectedType); + Assert.Equal(2, attributes.ParserVersion); + + AiFileTranslationsStatus.AttributesObject.ErrorObject? error = attributes.Error; + ArgumentNullException.ThrowIfNull(error); + Assert.Equal(AiFileTranslationsStage.Import, error.Stage); + Assert.Equal("Failed to parse file", error.Message); + + DateTimeOffset date = DateTimeOffset.Parse("2026-01-23T11:26:54+00:00"); + Assert.Equal(date, response.CreatedAt); + Assert.Equal(date, response.UpdatedAt); + Assert.Equal(date, response.StartedAt); + Assert.Equal(date, response.FinishedAt); + } + } +} \ No newline at end of file