From 82dca56fbd114a2693555df98d8b58d40a4e1108 Mon Sep 17 00:00:00 2001 From: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:18:31 +0000 Subject: [PATCH] feat(api): Model Ingestion api (#420) * First pass * format * subscriptions * Fixes * fake a release * fix tests * subscription tests * tests(sdk): fix model ingestion sub test' * tests(integration): fix model ingestion test expectations * todos * revert this too * Filter Integration-Internal tests * use a different trait * capitalize * codecov tweaks * fix * add requeue and start processing * requeue --------- Co-authored-by: Gergo Jedlicska --- .github/workflows/integration-test.yml | 17 +- .github/workflows/pr.yml | 10 +- .github/workflows/release.yml | 2 + .tool-versions | 1 + Speckle.Sdk.slnx | 1 + src/Speckle.Sdk/Api/GraphQL/Client.cs | 2 + .../Enums/FileUploadConversionStatus.cs | 7 +- .../Api/GraphQL/Enums/ModelIngestionStatus.cs | 14 + .../ProjectCommentsUpdatedMessageType.cs | 3 + .../ProjectFileImportUpdatedMessageType.cs | 3 + ...ProjectModelIngestionUpdatedMessageType.cs | 13 + .../Enums/ProjectModelsUpdatedMessageType.cs | 3 + .../ProjectPendingModelsUpdatedMessageType.cs | 3 + .../Enums/ProjectUpdatedMessageType.cs | 3 + .../ProjectVersionsUpdatedMessageType.cs | 3 + .../Api/GraphQL/Enums/ProjectVisibility.cs | 4 + .../Api/GraphQL/Enums/ResourceType.cs | 6 +- .../Enums/UserProjectsUpdatedMessageType.cs | 3 + .../GraphQL/Inputs/ModelIngestionInputs.cs | 65 +++ .../Api/GraphQL/Models/ModelIngestion.cs | 12 + .../Models/ModelIngestionStatusData.cs | 9 + .../GraphQL/Models/SubscriptionMessages.cs | 39 +- .../Resources/ModelIngestionResource.cs | 440 ++++++++++++++++++ .../GraphQL/Resources/SubscriptionResource.cs | 55 +++ ...s.CancelNonExistentIngestion.verified.json | 8 + ...eIngestionNonExistentProject.verified.json | 8 + ...UpdateNonExistentNonExistent.verified.json | 8 + .../ModelIngestionResourceExceptionalTests.cs | 74 +++ .../Resources/ModelIngestionResourceTests.cs | 177 +++++++ .../Resources/SubscriptionResourceTests.cs | 82 +++- 30 files changed, 1045 insertions(+), 30 deletions(-) create mode 100644 .tool-versions create mode 100644 src/Speckle.Sdk/Api/GraphQL/Enums/ModelIngestionStatus.cs create mode 100644 src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelIngestionUpdatedMessageType.cs create mode 100644 src/Speckle.Sdk/Api/GraphQL/Inputs/ModelIngestionInputs.cs create mode 100644 src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestion.cs create mode 100644 src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestionStatusData.cs create mode 100644 src/Speckle.Sdk/Api/GraphQL/Resources/ModelIngestionResource.cs create mode 100644 tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CancelNonExistentIngestion.verified.json create mode 100644 tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CreateIngestionNonExistentProject.verified.json create mode 100644 tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.UpdateNonExistentNonExistent.verified.json create mode 100644 tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.cs create mode 100644 tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceTests.cs diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index db7638a9..76d5fe88 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -6,10 +6,12 @@ on: docker-compose-file: required: true type: string - use-github-container-registry: + use-internal-image: default: false type: boolean - + secrets: + CODECOV_TOKEN: + required: true jobs: integration-test: env: @@ -27,7 +29,7 @@ jobs: cache-dependency-path: "**/packages.lock.json" - name: 🔐 Login to Github Container Registry - if: ${{ inputs.use-github-container-registry }} + if: ${{ inputs.use-internal-image }} uses: docker/login-action@v3 with: registry: "ghcr.io" @@ -43,11 +45,18 @@ jobs: - name: 🏗️ Build run: dotnet build ${{ env.Solution }} --configuration Release --no-restore -warnaserror - - name: 🔨 Integration Tests + - name: 🔨 Integration Tests against Public Server + if: ${{ !inputs.use-internal-image }} + run: dotnet test ${{ env.Solution }} --filter "(Category=Integration)&(Server!=Internal)" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage + + - name: 🔨 Integration Tests against Internal Server + if: ${{ inputs.use-internal-image }} run: dotnet test ${{ env.Solution }} --filter "Category=Integration" --configuration Release --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 + continue-on-error: true with: + fail_ci_if_error: true files: tests/**/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 89453c4f..8068e7c2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -39,7 +39,9 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 + continue-on-error: true with: + fail_ci_if_error: true files: tests/**/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} @@ -47,9 +49,13 @@ jobs: uses: "./.github/workflows/integration-test.yml" with: docker-compose-file: "docker-compose-internal.yml" - use-github-container-registry: true - + use-internal-image: true + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + integration-test-public: uses: "./.github/workflows/integration-test.yml" with: docker-compose-file: "docker-compose.yml" + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c87bcff..669d0541 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,9 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 + continue-on-error: true with: + fail_ci_if_error: true files: tests/**/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..cd415749 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +dotnet 8.0.400 diff --git a/Speckle.Sdk.slnx b/Speckle.Sdk.slnx index aee97c65..8b77f1f3 100644 --- a/Speckle.Sdk.slnx +++ b/Speckle.Sdk.slnx @@ -17,6 +17,7 @@ + diff --git a/src/Speckle.Sdk/Api/GraphQL/Client.cs b/src/Speckle.Sdk/Api/GraphQL/Client.cs index 7d6099de..abe96585 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Client.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Client.cs @@ -35,6 +35,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient public WorkspaceResource Workspace { get; } public ServerResource Server { get; } public FileImportResource FileImport { get; } + public ModelIngestionResource Ingestion { get; } public Uri ServerUrl => new(Account.serverInfo.url); @@ -71,6 +72,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient Workspace = new(this); Server = new(this); FileImport = new(this, blobApiFactory.Create(account)); + Ingestion = new(this); } [AutoInterfaceIgnore] diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/FileUploadConversionStatus.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/FileUploadConversionStatus.cs index 327f947e..21f0e49d 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/FileUploadConversionStatus.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/FileUploadConversionStatus.cs @@ -1,6 +1,9 @@ -namespace Speckle.Sdk.Api.GraphQL.Enums; +// ReSharper disable InconsistentNaming +namespace Speckle.Sdk.Api.GraphQL.Enums; -//This enum isn't explicitly defined in the schema, instead its usages are int typed (But represent an enum) +/// +/// This enum isn't explicitly defined in the schema, instead its usages are int typed (But represent an enum) +/// public enum FileUploadConversionStatus { Queued = 0, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ModelIngestionStatus.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ModelIngestionStatus.cs new file mode 100644 index 00000000..85ca92dc --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ModelIngestionStatus.cs @@ -0,0 +1,14 @@ +// ReSharper disable InconsistentNaming +namespace Speckle.Sdk.Api.GraphQL.Enums; + +/// +/// string based enum +/// +public enum ModelIngestionStatus +{ + cancelled, + failed, + processing, + queued, + success, +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectCommentsUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectCommentsUpdatedMessageType.cs index 2500425d..8bd5937c 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectCommentsUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectCommentsUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectCommentsUpdatedMessageType { ARCHIVED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectFileImportUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectFileImportUpdatedMessageType.cs index b1947272..9c53eebd 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectFileImportUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectFileImportUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectFileImportUpdatedMessageType { CREATED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelIngestionUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelIngestionUpdatedMessageType.cs new file mode 100644 index 00000000..2ec41934 --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelIngestionUpdatedMessageType.cs @@ -0,0 +1,13 @@ +// ReSharper disable InconsistentNaming +namespace Speckle.Sdk.Api.GraphQL.Enums; + +/// +/// string based enum +/// +public enum ProjectModelIngestionUpdatedMessageType +{ + cancellationRequested, + created, + deleted, + updated, +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelsUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelsUpdatedMessageType.cs index b6316fb0..11dc5440 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelsUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectModelsUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectModelsUpdatedMessageType { CREATED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectPendingModelsUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectPendingModelsUpdatedMessageType.cs index 8e24a9c8..d2735ab9 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectPendingModelsUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectPendingModelsUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectPendingModelsUpdatedMessageType { CREATED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectUpdatedMessageType.cs index a056708c..7e51e635 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectUpdatedMessageType { DELETED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVersionsUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVersionsUpdatedMessageType.cs index 977c93f8..2f83fbd4 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVersionsUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVersionsUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectVersionsUpdatedMessageType { CREATED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVisibility.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVisibility.cs index b0692127..c019dd38 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVisibility.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ProjectVisibility.cs @@ -1,5 +1,9 @@ +// ReSharper disable InconsistentNaming namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ProjectVisibility { Private, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/ResourceType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/ResourceType.cs index d592e308..ecb8ba29 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/ResourceType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/ResourceType.cs @@ -1,5 +1,9 @@ -namespace Speckle.Sdk.Api.GraphQL.Enums; +// ReSharper disable InconsistentNaming +namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum ResourceType { commit, diff --git a/src/Speckle.Sdk/Api/GraphQL/Enums/UserProjectsUpdatedMessageType.cs b/src/Speckle.Sdk/Api/GraphQL/Enums/UserProjectsUpdatedMessageType.cs index 1ad08ce3..4891c110 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Enums/UserProjectsUpdatedMessageType.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Enums/UserProjectsUpdatedMessageType.cs @@ -1,5 +1,8 @@ namespace Speckle.Sdk.Api.GraphQL.Enums; +/// +/// string based enum +/// public enum UserProjectsUpdatedMessageType { ADDED, diff --git a/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelIngestionInputs.cs b/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelIngestionInputs.cs new file mode 100644 index 00000000..9f243b11 --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Inputs/ModelIngestionInputs.cs @@ -0,0 +1,65 @@ +using Speckle.Newtonsoft.Json; +using Speckle.Sdk.Api.GraphQL.Enums; + +namespace Speckle.Sdk.Api.GraphQL.Inputs; + +public record SourceDataInput( + string sourceApplicationSlug, + string sourceApplicationVersion, + string? fileName, + long? fileSizeBytes +); + +public record ModelIngestionCreateInput( + string modelId, + string projectId, + string progressMessage, + SourceDataInput sourceData +); + +public record ModelIngestionUpdateInput(string ingestionId, string projectId, string progressMessage, double? progress); + +public record ModelIngestionSuccessInput(string ingestionId, string projectId, string rootObjectId); + +public record ModelIngestionFailedInput( + string ingestionId, + string projectId, + string errorReason, + string? errorStacktrace +) +{ + public static ModelIngestionFailedInput FromException(string ingestionId, string projectId, Exception ex) + { + return new ModelIngestionFailedInput(ingestionId, projectId, ex.Message, ex.ToString()); + } +} + +public record ModelIngestionCancelledInput(string ingestionId, string projectId, string cancellationMessage); + +public record ModelIngestionStartProcessingInput( + string ingestionId, + string projectId, + string progressMessage, + SourceDataInput sourceData +); + +public record ModelIngestionRequeueInput(string ingestionId, string projectId, string progressMessage); + +public record ProjectModelIngestionSubscriptionInput( + string projectId, + ModelIngestionReference ingestionReference, + [property: JsonIgnore] ProjectModelIngestionUpdatedMessageType messageType +) +{ + // The Newtonsoft serializer is setup to handle SCREAMING_CASE enums. + // But the API requires the enum to look exactly like they are + [JsonProperty(nameof(messageType))] + public string serializedType => messageType.ToString(); +} + +/// +/// @oneOf i.e. server expects either or , but not both. +/// +/// +/// +public record ModelIngestionReference(string? ingestionId, string? modelId); diff --git a/src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestion.cs b/src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestion.cs new file mode 100644 index 00000000..58d0dc67 --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestion.cs @@ -0,0 +1,12 @@ +namespace Speckle.Sdk.Api.GraphQL.Models; + +public sealed class ModelIngestion +{ + public required string id { get; init; } + public required DateTime createdAt { get; init; } + public required DateTime updatedAt { get; init; } + public required string modelId { get; init; } + public required bool cancellationRequested { get; init; } + public required ModelIngestionStatusData statusData { get; init; } + // public required LimitedUser user { get; init; } +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestionStatusData.cs b/src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestionStatusData.cs new file mode 100644 index 00000000..a195bdfc --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Models/ModelIngestionStatusData.cs @@ -0,0 +1,9 @@ +using Speckle.Sdk.Api.GraphQL.Enums; + +namespace Speckle.Sdk.Api.GraphQL.Models; + +public sealed class ModelIngestionStatusData +{ + public required ModelIngestionStatus status { get; init; } + public required string? progressMessage { get; init; } +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Models/SubscriptionMessages.cs b/src/Speckle.Sdk/Api/GraphQL/Models/SubscriptionMessages.cs index 327bac77..0f4ebb07 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Models/SubscriptionMessages.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Models/SubscriptionMessages.cs @@ -6,10 +6,10 @@ namespace Speckle.Sdk.Api.GraphQL.Models; public sealed class UserProjectsUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public UserProjectsUpdatedMessageType type { get; init; } + public required UserProjectsUpdatedMessageType type { get; init; } public Project? project { get; init; } } @@ -17,10 +17,10 @@ public sealed class UserProjectsUpdatedMessage : EventArgs public sealed class ProjectCommentsUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public ProjectCommentsUpdatedMessageType type { get; init; } + public required ProjectCommentsUpdatedMessageType type { get; init; } public Comment? comment { get; init; } } @@ -28,10 +28,10 @@ public sealed class ProjectCommentsUpdatedMessage : EventArgs public sealed class ProjectFileImportUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public ProjectFileImportUpdatedMessageType type { get; init; } + public required ProjectFileImportUpdatedMessageType type { get; init; } public FileUpload? upload { get; init; } } @@ -39,10 +39,10 @@ public sealed class ProjectFileImportUpdatedMessage : EventArgs public sealed class ProjectModelsUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public ProjectModelsUpdatedMessageType type { get; init; } + public required ProjectModelsUpdatedMessageType type { get; init; } public Model? model { get; init; } } @@ -50,10 +50,10 @@ public sealed class ProjectModelsUpdatedMessage : EventArgs public sealed class ProjectPendingModelsUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public ProjectPendingModelsUpdatedMessageType type { get; init; } + public required ProjectPendingModelsUpdatedMessageType type { get; init; } public FileUpload? model { get; init; } } @@ -61,10 +61,10 @@ public sealed class ProjectPendingModelsUpdatedMessage : EventArgs public sealed class ProjectUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public ProjectUpdatedMessageType type { get; init; } + public required ProjectUpdatedMessageType type { get; init; } public Project? project { get; init; } } @@ -72,13 +72,22 @@ public sealed class ProjectUpdatedMessage : EventArgs public sealed class ProjectVersionsUpdatedMessage : EventArgs { [JsonRequired] - public string id { get; init; } + public required string id { get; init; } [JsonRequired] - public ProjectVersionsUpdatedMessageType type { get; init; } + public required ProjectVersionsUpdatedMessageType type { get; init; } [JsonRequired] - public string modelId { get; init; } + public required string modelId { get; init; } public Version? version { get; init; } } + +public sealed class ProjectModelIngestionUpdatedMessage : EventArgs +{ + [JsonRequired] + public required ModelIngestion modelIngestion { get; init; } + + [JsonRequired] + public required ProjectModelIngestionUpdatedMessageType type { get; init; } +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Resources/ModelIngestionResource.cs b/src/Speckle.Sdk/Api/GraphQL/Resources/ModelIngestionResource.cs new file mode 100644 index 00000000..bec1c5e9 --- /dev/null +++ b/src/Speckle.Sdk/Api/GraphQL/Resources/ModelIngestionResource.cs @@ -0,0 +1,440 @@ +using GraphQL; +using Speckle.Sdk.Api.GraphQL.Inputs; +using Speckle.Sdk.Api.GraphQL.Models; +using Speckle.Sdk.Api.GraphQL.Models.Responses; + +namespace Speckle.Sdk.Api.GraphQL.Resources; + +/// +/// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above +/// +public sealed class ModelIngestionResource +{ + private readonly ISpeckleGraphQLClient _client; + + internal ModelIngestionResource(ISpeckleGraphQLClient client) + { + _client = client; + } + + /// + /// Create a new model ingestion + /// + /// + /// The model ingestion created will have a processing state (not queued). This mutation is designed to be used + /// by client/connectors that are immediately processing + /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + /// + /// + /// + /// + /// + public async Task Create( + ModelIngestionCreateInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionCreate($input: ModelIngestionCreateInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: create(input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } + + /// + /// For File Import / Cloud integrations only + /// + /// + /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + /// + /// + /// + /// + /// + public async Task StartProcessing( + ModelIngestionStartProcessingInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionStartProcessing($input: ModelIngestionStartProcessingInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: startProcessing(input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } + + /// + /// For File Import / Cloud integrations only + /// + /// + /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + /// + /// + /// + /// + /// + public async Task Requeue( + ModelIngestionRequeueInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionStartProcessing($input: ModelIngestionRequeueInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: requeue(input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } + + /// + /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + /// + /// + /// + /// + /// + public async Task UpdateProgress( + ModelIngestionUpdateInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionUpdateProgress( + $input: ModelIngestionUpdateInput! + ) { + data: projectMutations { + data: modelIngestionMutations { + data: updateProgress(input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } + + /// + /// Request that the server completes the ingestion by creating a version + /// If successful, the job will be in a terminal "successful" state. + /// + /// + /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + /// + /// + /// + /// + /// + /// The version id + /// + public async Task Complete(ModelIngestionSuccessInput input, CancellationToken cancellationToken = default) + { + //language=graphql + const string QUERY = """ + mutation IngestionComplete($input: ModelIngestionSuccessInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: completeWithVersion(input: $input) { + data:statusData { + ... on ModelIngestionSuccessStatus { + data:versionId + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest< + RequiredResponse>>>> + >(request, cancellationToken) + .ConfigureAwait(false); + + return res.data.data.data.data.data; + } + + /// + /// Fail the job with an error. + /// + /// + /// For requested user cancellation, use instead
+ /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + ///
+ /// + /// + /// + /// + /// + /// + public async Task FailWithError( + ModelIngestionFailedInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionFailWithError($input: ModelIngestionFailedInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: failWithError(input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } + + /// + /// Fail the ingestion with a canceled status. + /// This should only be done if the user has explicitly requested cancellation + /// Other forms of cancellation use . + /// The ingestion should then enter a terminal "canceled" state.
+ /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + ///
+ /// + /// + /// + /// + /// + /// + public async Task FailWithCancel( + ModelIngestionCancelledInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionFailWithCancel($input: ModelIngestionCancelledInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: failWithCancel(input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } + + /// + /// Request that the is canceled. + /// + /// + /// Note simply calling this mutation does not imediatly cancel, it doesn't even guarantee it will be canceled at all. + /// It's up to the client to observe this cancellation request + /// via + /// and report it as canceled via + /// See "cooperative cancellation pattern"
+ /// Model Ingestion API is available for server versions 3.0.3-alpha.583 and above + ///
+ /// + /// + /// + /// + /// + /// + public async Task RequestCancellation( + ModelIngestionCancelledInput input, + CancellationToken cancellationToken = default + ) + { + //language=graphql + const string QUERY = """ + mutation IngestionRequestCancellation($input: ModelIngestionRequestCancellationInput!) { + data: projectMutations { + data: modelIngestionMutations { + data: requestCancellation (input: $input) { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + } + } + } + """; + + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + var res = await _client + .ExecuteGraphQLRequest>>>( + request, + cancellationToken + ) + .ConfigureAwait(false); + + return res.data.data.data; + } +} diff --git a/src/Speckle.Sdk/Api/GraphQL/Resources/SubscriptionResource.cs b/src/Speckle.Sdk/Api/GraphQL/Resources/SubscriptionResource.cs index f23edae9..9a014cff 100644 --- a/src/Speckle.Sdk/Api/GraphQL/Resources/SubscriptionResource.cs +++ b/src/Speckle.Sdk/Api/GraphQL/Resources/SubscriptionResource.cs @@ -1,4 +1,5 @@ using GraphQL; +using Speckle.Sdk.Api.GraphQL.Enums; using Speckle.Sdk.Api.GraphQL.Inputs; using Speckle.Sdk.Api.GraphQL.Models; using Speckle.Sdk.Api.GraphQL.Models.Responses; @@ -212,6 +213,60 @@ public sealed class SubscriptionResource : IDisposable return subscription; } + /// Subscribe to a cancellation request being made for a Model Ingestion + /// + /// + public Subscription CreateProjectModelIngestionUpdatedSubscription( + ProjectModelIngestionSubscriptionInput input + ) + { + //language=graphql + const string QUERY = """ + subscription IngestionUpdated($input: ProjectModelIngestionSubscriptionInput!) { + data: projectModelIngestionUpdated(input: $input) { + modelIngestion { + id + createdAt + updatedAt + modelId + cancellationRequested + statusData { + ... on HasModelIngestionStatus { + status + } + ... on HasProgressMessage { + progressMessage + } + } + } + type + } + } + """; + GraphQLRequest request = new() { Query = QUERY, Variables = new { input } }; + + Subscription subscription = new(_client, request); + _subscriptions.Add(subscription); + return subscription; + } + + /// Subscribe to a cancellation request being made for a Model Ingestion + /// + /// + public Subscription CreateProjectModelIngestionCancellationRequestedSubscription( + string ingestionId, + string projectId + ) + { + return CreateProjectModelIngestionUpdatedSubscription( + new ProjectModelIngestionSubscriptionInput( + projectId, + new(ingestionId, null), + ProjectModelIngestionUpdatedMessageType.cancellationRequested + ) + ); + } + public void Dispose() { foreach (var subscription in _subscriptions) diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CancelNonExistentIngestion.verified.json b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CancelNonExistentIngestion.verified.json new file mode 100644 index 00000000..e743803c --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CancelNonExistentIngestion.verified.json @@ -0,0 +1,8 @@ +{ + "Type": "AggregateException", + "InnerException": { + "Data": {}, + "Message": "NOT_FOUND_ERROR: Model ingestion not found", + "Type": "SpeckleGraphQLException" + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CreateIngestionNonExistentProject.verified.json b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CreateIngestionNonExistentProject.verified.json new file mode 100644 index 00000000..04a2199a --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.CreateIngestionNonExistentProject.verified.json @@ -0,0 +1,8 @@ +{ + "Type": "AggregateException", + "InnerException": { + "Data": {}, + "Message": "STREAM_NOT_FOUND: Project not found", + "Type": "SpeckleGraphQLStreamNotFoundException" + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.UpdateNonExistentNonExistent.verified.json b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.UpdateNonExistentNonExistent.verified.json new file mode 100644 index 00000000..e743803c --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.UpdateNonExistentNonExistent.verified.json @@ -0,0 +1,8 @@ +{ + "Type": "AggregateException", + "InnerException": { + "Data": {}, + "Message": "NOT_FOUND_ERROR: Model ingestion not found", + "Type": "SpeckleGraphQLException" + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.cs new file mode 100644 index 00000000..0bae1526 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceExceptionalTests.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Speckle.Sdk.Api; +using Speckle.Sdk.Api.GraphQL.Inputs; +using Speckle.Sdk.Api.GraphQL.Models; +using Speckle.Sdk.Api.GraphQL.Resources; +using Speckle.Sdk.Host; +using Speckle.Sdk.Models; + +namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources; + +[Trait("Server", "Internal")] +public sealed class ModelIngestionResourceExceptionalTests : IAsyncLifetime +{ + private IClient _testUser; + private ModelIngestionResource Sut => _testUser.Ingestion; + private Project _project; + private Model _model; + + public Task DisposeAsync() => Task.CompletedTask; + + public async Task InitializeAsync() + { + TypeLoader.Reset(); + TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly()); + + _testUser = await Fixtures.SeedUserWithClient(); + _project = await _testUser.Project.Create(new("Test project", "", null)); + _model = await _testUser.Model.Create(new("Test Model 1", "", _project.id)); + } + + [Fact] + public async Task CreateIngestionNonExistentProject() + { + var createInput = new ModelIngestionCreateInput( + _model.id, + "Doesn't exist...", + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + + var ex = await Assert.ThrowsAsync(async () => + { + _ = await Sut.Create(createInput); + }); + await Verify(ex); + } + + [Fact] + public async Task UpdateNonExistentNonExistent() + { + var updateInput = new ModelIngestionUpdateInput("Doesn't exist", _project.id, "Can't be", 0.5); + + var ex = await Assert.ThrowsAsync(async () => + { + _ = await Sut.UpdateProgress(updateInput); + }); + await Verify(ex); + } + + [Fact] + public async Task CancelNonExistentIngestion() + { + var input = new ModelIngestionCancelledInput( + "Non-existent-ingestion", + _project.id, + cancellationMessage: "This was cancelled for testing purposes" + ); + var ex = await Assert.ThrowsAsync(async () => + { + _ = await Sut.FailWithCancel(input); + }); + await Verify(ex); + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceTests.cs new file mode 100644 index 00000000..51f52122 --- /dev/null +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/ModelIngestionResourceTests.cs @@ -0,0 +1,177 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Speckle.Sdk.Api; +using Speckle.Sdk.Api.GraphQL.Enums; +using Speckle.Sdk.Api.GraphQL.Inputs; +using Speckle.Sdk.Api.GraphQL.Models; +using Speckle.Sdk.Api.GraphQL.Resources; +using Speckle.Sdk.Host; +using Speckle.Sdk.Models; +using Speckle.Sdk.Transports; +using Version = Speckle.Sdk.Api.GraphQL.Models.Version; + +namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources; + +[Trait("Server", "Internal")] +public sealed class ModelIngestionResourceTests : IAsyncLifetime +{ + private IClient _testUser; + private ModelIngestionResource Sut => _testUser.Ingestion; + private Project _project; + private Model _model; + private IOperations _operations; + + public Task DisposeAsync() => Task.CompletedTask; + + public async Task InitializeAsync() + { + TypeLoader.Reset(); + TypeLoader.Initialize(typeof(Base).Assembly, Assembly.GetExecutingAssembly()); + var serviceProvider = TestServiceSetup.GetServiceProvider(); + _operations = serviceProvider.GetRequiredService(); + + _testUser = await Fixtures.SeedUserWithClient(); + _project = await _testUser.Project.Create(new("Test project", "", null)); + _model = await _testUser.Model.Create(new("Test Model 1", "", _project.id)); + } + + [Fact] + public async Task CreateAndError() + { + var createInput = new ModelIngestionCreateInput( + _model.id, + _project.id, + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + ModelIngestion ingest = await Sut.Create(createInput); + + var errorInput = new ModelIngestionFailedInput(ingest.id, _project.id, "A bad thing happened", "Over hear!"); + var res = await Sut.FailWithError(errorInput); + Assert.Equal(ingest.id, res.id); + } + + [Fact] + public async Task CreateAndUpdate() + { + var createInput = new ModelIngestionCreateInput( + _model.id, + _project.id, + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + ModelIngestion ingest = await Sut.Create(createInput); + + await Update(null, "None"); + await Update(0.1, "0.1"); + await Update(0.5, "Whoa-oh! We're half way there!"); + await Update(1, "Finished"); + await Update(0.2, "Back to processing again"); + + async Task Update(double? progress, string message) + { + var updateInput = new ModelIngestionUpdateInput(ingest.id, _project.id, message, progress); + var res = await Sut.UpdateProgress(updateInput); + + Assert.Equal(message, res.statusData.progressMessage); + Assert.False(res.cancellationRequested); + Assert.Equal(ModelIngestionStatus.processing, res.statusData.status); + } + } + + [Fact] + public async Task CreateAndCancel() + { + var createInput = new ModelIngestionCreateInput( + _model.id, + _project.id, + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + ModelIngestion ingest = await Sut.Create(createInput); + + var input = new ModelIngestionCancelledInput( + ingest.id, + _project.id, + cancellationMessage: "This was cancelled for testing purposes" + ); + var res = await Sut.FailWithCancel(input); + Assert.Equal(ingest.id, res.id); + } + + [Fact] + public async Task CreateAndComplete() + { + ModelIngestionCreateInput createInput = new( + _model.id, + _project.id, + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + ModelIngestion ingest = await Sut.Create(createInput); + + Base myObject = Fixtures.GenerateNestedObject(); + var sendResult = await _operations.Send2( + _testUser.ServerUrl, + _project.id, + _testUser.Account.token, + myObject, + new Progress(x => + { + var updateInput = new ModelIngestionUpdateInput( + ingest.id, + _project.id, + $"{x.Count} / {x.Total}", + x.Total == null ? null : x.Count / x.Total + ); + _ = Sut.UpdateProgress(updateInput).Result; + }), + CancellationToken.None, + new(true, true) + ); + + ModelIngestionSuccessInput finish = new(ingest.id, _project.id, sendResult.RootId); + string versionId = await Sut.Complete(finish); + Version version = await _testUser.Version.Get(versionId, _project.id); + Assert.Equal(version.id, versionId); + Assert.Equal(sendResult.RootId, version.referencedObject); + } + + [Fact] + public async Task TestRequeue() + { + //Not sure if is desirable that ingestions created by the modelIngestionMutations.create mutation can be re-queued + //But the server allows it, so we test it + var createInput = new ModelIngestionCreateInput( + _model.id, + _project.id, + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + var ingestion = await Sut.Create(createInput); + var res = await Sut.Requeue(new(ingestion.id, _project.id, "we'll try and requeue this ingestion")); + + Assert.Equal(ingestion.id, res.id); + Assert.Equal(ModelIngestionStatus.queued, res.statusData.status); + } + + [Fact] + public async Task TestStartProcessing() + { + //Not sure if is desirable that StartProcessing can be used by ingestions created by the modelIngestionMutations.create mutation + //But the server allows it, so we test it + var createInput = new ModelIngestionCreateInput( + _model.id, + _project.id, + "Starting processing", + new(".NET test runner", "0.0.0", null, null) + ); + var ingestion = await Sut.Create(createInput); + var res = await Sut.StartProcessing( + new(ingestion.id, _project.id, "", new SourceDataInput("what", "happens", "now", 0)) + ); + + Assert.Equal(ingestion.id, res.id); + Assert.Equal(ModelIngestionStatus.processing, res.statusData.status); + } +} diff --git a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/SubscriptionResourceTests.cs b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/SubscriptionResourceTests.cs index f56fffa0..63e4f780 100644 --- a/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/SubscriptionResourceTests.cs +++ b/tests/Speckle.Sdk.Tests.Integration/Api/GraphQL/Resources/SubscriptionResourceTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using Speckle.Sdk.Api; using Speckle.Sdk.Api.GraphQL.Enums; using Speckle.Sdk.Api.GraphQL.Inputs; @@ -11,7 +11,7 @@ namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources; public class SubscriptionResourceTests : IAsyncLifetime { #if DEBUG - private const int WAIT_PERIOD = 3000; // WSL is slow AF, so for local runs, we're being extra generous + private const int WAIT_PERIOD = 4000; // WSL is slow AF, so for local runs, we're being extra generous #else private const int WAIT_PERIOD = 400; // For CI runs, a much smaller wait time is acceptable #endif @@ -80,15 +80,15 @@ public class SubscriptionResourceTests : IAsyncLifetime public async Task ProjectUpdated_SubscriptionIsCalled() { TaskCompletionSource tcs = new(); - using var sub = Sut.CreateProjectUpdatedSubscription(_testProject.id); + using Subscription sub = Sut.CreateProjectUpdatedSubscription(_testProject.id); sub.Listeners += (_, message) => tcs.SetResult(message); await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup - var input = new ProjectUpdateInput(_testProject.id, "This is my new name"); - var created = await _testUser.Project.Update(input); + ProjectUpdateInput input = new(_testProject.id, "This is my new name"); + Project created = await _testUser.Project.Update(input); - var subscriptionMessage = await tcs.Task; + ProjectUpdatedMessage subscriptionMessage = await tcs.Task; subscriptionMessage.Should().NotBeNull(); subscriptionMessage.id.Should().Be(created.id); @@ -135,4 +135,74 @@ public class SubscriptionResourceTests : IAsyncLifetime subscriptionMessage.type.Should().Be(ProjectCommentsUpdatedMessageType.CREATED); subscriptionMessage.comment.Should().NotBeNull(); } + + [Fact(Timeout = TIMEOUT), Trait("Server", "Internal")] + public async Task ProjectModelIngestionCancellationRequested_SubscriptionIsCalled() + { + ModelIngestion ingestion = await _testUser.Ingestion.Create( + new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null)) + ); + TaskCompletionSource tcs = new(); + + using var sub = Sut.CreateProjectModelIngestionCancellationRequestedSubscription(ingestion.id, _testProject.id); + sub.Listeners += (_, message) => tcs.SetResult(message); + + await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup + + await _testUser.Ingestion.RequestCancellation(new(ingestion.id, _testProject.id, "please cancel")); + + var subscriptionMessage = await tcs.Task; + + subscriptionMessage.Should().NotBeNull(); + subscriptionMessage.type.Should().Be(ProjectModelIngestionUpdatedMessageType.cancellationRequested); + subscriptionMessage.modelIngestion.id.Should().Be(ingestion.id); + } + + [Fact(Timeout = TIMEOUT), Trait("Server", "Internal")] + public async Task ProjectModelIngestionUpdate_UpdateSubscriptionIs() + { + ModelIngestion ingestion = await _testUser.Ingestion.Create( + new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null)) + ); + TaskCompletionSource tcs = new(); + + using var sub = Sut.CreateProjectModelIngestionUpdatedSubscription( + new( + _testProject.id, + new ModelIngestionReference(ingestion.id, null), + ProjectModelIngestionUpdatedMessageType.updated + ) + ); + sub.Listeners += (_, message) => tcs.SetResult(message); + + await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup + + await _testUser.Ingestion.UpdateProgress(new(ingestion.id, _testProject.id, "Here's an update", 0.314)); + + var subscriptionMessage = await tcs.Task; + + subscriptionMessage.Should().NotBeNull(); + subscriptionMessage.type.Should().Be(ProjectModelIngestionUpdatedMessageType.updated); + subscriptionMessage.modelIngestion.id.Should().Be(ingestion.id); + } + + [Fact(Timeout = TIMEOUT), Trait("Server", "Internal")] + public async Task ProjectModelIngestionUpdate_CancelSubscriptionIsNotCalled() + { + ModelIngestion ingestion = await _testUser.Ingestion.Create( + new(_testModel.id, _testProject.id, "", new(".NET test", "0.0.0", null, null)) + ); + TaskCompletionSource tcs = new(); + + using var sub = Sut.CreateProjectModelIngestionCancellationRequestedSubscription(ingestion.id, _testProject.id); + sub.Listeners += (_, message) => tcs.SetResult(message); + + await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup + + await _testUser.Ingestion.UpdateProgress(new(ingestion.id, _testProject.id, "this shouldn't cancel", null)); + + await Task.Delay(WAIT_PERIOD); // Give time to subscription to maybe fire + + tcs.Task.IsCompleted.Should().BeFalse(); + } }