Compare commits

..

4 Commits

Author SHA1 Message Date
Jedd Morgan 3fbd9c17ba format
.NET Build and Publish / build (push) Has been cancelled
2025-11-24 18:41:10 +00:00
Jedd Morgan 937eb94730 First pass 2025-11-24 18:40:17 +00:00
Jedd Morgan b0da4510bf Merge pull request #410 from specklesystems/jrm/subscription-tests
test(subscriptions): Make subscription tests a bit more reliable
2025-11-05 11:28:23 +00:00
Jedd Morgan 96392d0d2f chore(ci): Reliable integration Tests (#418)
* remove bad tests

* add pack to PR workflow
2025-11-05 11:22:07 +00:00
10 changed files with 455 additions and 115 deletions
+3
View File
@@ -33,6 +33,9 @@ jobs:
- name: 🔨 Unit Tests
run: dotnet test ${{ env.Solution }} --configuration Release --filter "Category!=Integration" --no-build --no-restore --verbosity=normal /p:AltCover=true /p:AltCoverAttributeFilter=ExcludeFromCodeCoverage
- name: 🎁 Pack
run: dotnet pack ${{ env.Solution }} --configuration Release --no-build
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
+1
View File
@@ -42,6 +42,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
ProjectSection(SolutionItems) = preProject
.github\workflows\pr.yml = .github\workflows\pr.yml
.github\workflows\release.yml = .github\workflows\release.yml
.github\workflows\integration-test.yml = .github\workflows\integration-test.yml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Speckle.Sdk.Tests.Performance", "tests\Speckle.Sdk.Tests.Performance\Speckle.Sdk.Tests.Performance.csproj", "{870E3396-E6F7-43AE-B120-E651FA4F46BD}"
+2
View File
@@ -35,6 +35,7 @@ public sealed class Client : ISpeckleGraphQLClient, IClient
public WorkspaceResource Workspace { get; }
public ServerResource Server { get; }
public FileImportResource FileImport { get; }
public IngestResource Ingest { 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));
Ingest = new(this);
}
[AutoInterfaceIgnore]
@@ -0,0 +1,19 @@
namespace Speckle.Sdk.Api.GraphQL.Inputs;
public record IngestCreateInput(
string fileName,
int? maxIdleTimeoutMinutes,
string modelId,
string projectId,
string sourceApplication,
string sourceApplicationVersion,
IReadOnlyDictionary<string, object?> sourceFileData
);
public record IngestFinishInput(string id, string? message, string objectId, string projectId);
public record IngestErrorInput(string errorReason, string errorStacktrace, string id, string projectId);
public record CancelRequestInput(string id, string projectId);
public record IngestUpdateInput(string id, double? progress, string? progressMessage, string projectId);
@@ -0,0 +1,23 @@
namespace Speckle.Sdk.Api.GraphQL.Models;
public sealed class Ingest
{
public required DateTime createdAt { get; init; }
public required string errorReason { get; init; }
public required string errorStacktrace { get; init; }
public required string fileName { get; init; }
public required string id { get; init; }
public required long maxIdleTimeoutMinutes { get; init; }
public required string modelId { get; init; }
public required Dictionary<string, object?> performanceData { get; init; }
public required double progress { get; init; }
public required string? progressMessage { get; init; }
public required string projectId { get; init; }
public required string sourceApplication { get; init; }
public required string sourceApplicationVersion { get; init; }
public required Dictionary<string, object?> sourceFileData { get; init; }
public required string status { get; init; }
public required DateTime updatedAt { get; init; }
public required string versionId { get; init; }
public required LimitedUser user { get; init; }
}
@@ -0,0 +1,253 @@
using GraphQL;
using Speckle.Sdk.Api.GraphQL.Inputs;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Api.GraphQL.Models.Responses;
using Version = Speckle.Sdk.Api.GraphQL.Models.Version;
namespace Speckle.Sdk.Api.GraphQL.Resources;
public sealed class IngestResource
{
private readonly ISpeckleGraphQLClient _client;
internal IngestResource(ISpeckleGraphQLClient client)
{
_client = client;
}
/// <param name="modelId"></param>
/// <param name="projectId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<ResourceCollection<Ingest>> GetIngests(
string modelId,
string projectId,
CancellationToken cancellationToken = default
)
{
//language=graphql
const string QUERY = """
query GetIngest($modelId: String!, $projectId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:ingests {
cursor
items {
createdAt
errorReason
errorStacktrace
fileName
id
maxIdleTimeoutMinutes
modelId
performanceData
progress
progressMessage
projectId
sourceApplication
sourceApplicationVersion
sourceFileData
status
updatedAt
versionId
user {
avatar
bio
company
id
name
role
verified
}
}
}
}
}
}
""";
var request = new GraphQLRequest { Query = QUERY, Variables = new { modelId, projectId } };
var response = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<ResourceCollection<Ingest>>>>>(
request,
cancellationToken
)
.ConfigureAwait(false);
return response.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> Update(IngestUpdateInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestUpdate($projectId: ID!, $input: IngestUpdateInput!) {
data: projectMutations {
data: ingestMutations(projectId: $projectId) {
data: update(input: $input)
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<Ingest> Create(IngestCreateInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestCreate($projectId: ID!, $input: IngestCreateInput!) {
data: projectMutations {
data:ingestMutations(projectId: $projectId) {
data:create(input: $input) {
createdAt
errorReason
errorStacktrace
fileName
id
maxIdleTimeoutMinutes
modelId
performanceData
progress
progressMessage
projectId
sourceApplication
sourceApplicationVersion
sourceFileData
status
updatedAt
versionId
user {
avatar
bio
company
id
name
role
verified
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<Ingest>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<Version> End(IngestFinishInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestEnd($projectId: ID!, $input: IngestFinishInput!) {
data: projectMutations {
data:ingestMutations(projectId: $projectId) {
data:end(input: $input) {
id
referencedObject
message
sourceApplication
createdAt
previewUrl
authorUser {
id
name
bio
company
verified
role
avatar
}
}
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<Version>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> Error(IngestErrorInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestError($projectId: ID!, $input: IngestErrorInput!) {
data: projectMutations {
data:ingestMutations(projectId: $projectId) {
data:error(input: $input)
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
/// <param name="input"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <inheritdoc cref="ISpeckleGraphQLClient.ExecuteGraphQLRequest{T}"/>
public async Task<bool> Cancel(CancelRequestInput input, CancellationToken cancellationToken = default)
{
//language=graphql
const string QUERY = """
mutation IngestCancel($projectId: ID!, $input: CancelRequestInput!) {
data:projectMutations {
data:ingestMutations(projectId: $projectId) {
data:cancel(input: $input)
}
}
}
""";
GraphQLRequest request = new() { Query = QUERY, Variables = new { input, input.projectId } };
var res = await _client
.ExecuteGraphQLRequest<RequiredResponse<RequiredResponse<RequiredResponse<bool>>>>(request, cancellationToken)
.ConfigureAwait(false);
return res.data.data.data;
}
}
@@ -0,0 +1,122 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
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;
using Speckle.Sdk.Transports;
namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
public class IngestResourceTests : IAsyncLifetime
{
private IClient _testUser;
private IngestResource Sut => _testUser.Ingest;
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<IOperations>();
_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 input = new IngestCreateInput(
"myTestFile",
1,
_model.id,
_project.id,
".NET",
"0.0.0",
new Dictionary<string, object?>()
);
Ingest ingest = await Sut.Create(input);
var errorInput = new IngestErrorInput("A bad thing happened", "Over hear!", ingest.id, _project.id);
var res = await Sut.Error(errorInput);
Assert.True(res);
var result = await Sut.GetIngests(_model.id, _project.id);
await Verify(result);
}
[Fact]
public async Task CreateAndCancel()
{
var input = new IngestCreateInput(
"myTestFile",
1,
_model.id,
_project.id,
".NET",
"0.0.0",
new Dictionary<string, object?>()
);
Ingest ingest = await Sut.Create(input);
var errorInput = new CancelRequestInput(ingest.id, _project.id);
var res = await Sut.Cancel(errorInput);
Assert.True(res);
var result = await Sut.GetIngests(_model.id, _project.id);
await Verify(result);
}
[Fact]
public async Task CreateAndEnd()
{
var create = new IngestCreateInput(
"myTestFile",
1,
_model.id,
_project.id,
".NET",
"0.0.0",
new Dictionary<string, object?>()
);
Ingest ingest = await Sut.Create(create);
var myObject = Fixtures.GenerateNestedObject();
var sendResult = await _operations.Send2(
_testUser.ServerUrl,
_project.id,
_testUser.Account.token,
myObject,
new Progress<ProgressArgs>(x =>
{
var updateInput = new IngestUpdateInput(
ingest.id,
x.Total == null ? null : x.Count / x.Total,
$"{x.Count} / {x.Total}",
_project.id
);
_ = Sut.Update(updateInput).Result;
}),
CancellationToken.None,
new(true, true)
);
var finish = new IngestFinishInput(ingest.id, "Yay! we completed", sendResult.RootId, _project.id);
var res = await Sut.End(finish);
Assert.NotNull(res);
var result = await Sut.GetIngests(_model.id, _project.id);
await Verify(result);
}
}
@@ -10,7 +10,12 @@ namespace Speckle.Sdk.Tests.Integration.API.GraphQL.Resources;
public class SubscriptionResourceTests : IAsyncLifetime
{
private const int WAIT_PERIOD = 300;
#if DEBUG
private const int WAIT_PERIOD = 3000; // 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
private const int TIMEOUT = WAIT_PERIOD + WAIT_PERIOD + 400;
private IClient _testUser;
private Project _testProject;
private Model _testModel;
@@ -32,105 +37,101 @@ public class SubscriptionResourceTests : IAsyncLifetime
_testVersion = await Fixtures.CreateVersion(_testUser, _testProject.id, _testModel.id);
}
[Fact]
[Fact(Timeout = TIMEOUT)]
public async Task UserProjectsUpdated_SubscriptionIsCalled()
{
UserProjectsUpdatedMessage? subscriptionMessage = null;
TaskCompletionSource<UserProjectsUpdatedMessage> tcs = new();
using var sub = Sut.CreateUserProjectsUpdatedSubscription();
sub.Listeners += (_, message) => subscriptionMessage = message;
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
var created = await _testUser.Project.Create(new(null, null, null));
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage!.id.Should().Be(created.id);
subscriptionMessage.id.Should().Be(created.id);
subscriptionMessage.type.Should().Be(UserProjectsUpdatedMessageType.ADDED);
subscriptionMessage.project.Should().NotBeNull();
}
[Fact]
[Fact(Timeout = TIMEOUT)]
public async Task ProjectModelsUpdated_SubscriptionIsCalled()
{
ProjectModelsUpdatedMessage? subscriptionMessage = null;
TaskCompletionSource<ProjectModelsUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectModelsUpdatedSubscription(_testProject.id);
sub.Listeners += (_, message) => subscriptionMessage = message;
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
CreateModelInput input = new("my model", "myDescription", _testProject.id);
var created = await _testUser.Model.Create(input);
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage!.id.Should().Be(created.id);
subscriptionMessage.id.Should().Be(created.id);
subscriptionMessage.type.Should().Be(ProjectModelsUpdatedMessageType.CREATED);
subscriptionMessage.model.Should().NotBeNull();
}
[Fact]
[Fact(Timeout = TIMEOUT)]
public async Task ProjectUpdated_SubscriptionIsCalled()
{
ProjectUpdatedMessage? subscriptionMessage = null;
TaskCompletionSource<ProjectUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectUpdatedSubscription(_testProject.id);
sub.Listeners += (_, message) => subscriptionMessage = message;
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);
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage!.id.Should().Be(created.id);
subscriptionMessage.id.Should().Be(created.id);
subscriptionMessage.type.Should().Be(ProjectUpdatedMessageType.UPDATED);
subscriptionMessage.project.Should().NotBeNull();
}
[Fact]
[Fact(Timeout = TIMEOUT)]
public async Task ProjectVersionsUpdated_SubscriptionIsCalled()
{
ProjectVersionsUpdatedMessage? subscriptionMessage = null;
TaskCompletionSource<ProjectVersionsUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectVersionsUpdatedSubscription(_testProject.id);
sub.Listeners += (_, message) => subscriptionMessage = message;
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
var created = await Fixtures.CreateVersion(_testUser, _testProject.id, _testModel.id);
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage!.id.Should().Be(created.id);
subscriptionMessage.id.Should().Be(created.id);
subscriptionMessage.type.Should().Be(ProjectVersionsUpdatedMessageType.CREATED);
subscriptionMessage.version.Should().NotBeNull();
}
[Fact(Skip = CommentResourceTests.SERVER_SKIP_MESSAGE)]
[Fact(Skip = CommentResourceTests.SERVER_SKIP_MESSAGE, Timeout = TIMEOUT)]
public async Task ProjectCommentsUpdated_SubscriptionIsCalled()
{
string resourceIdString = $"{_testProject.id},{_testModel.id},{_testVersion}";
ProjectCommentsUpdatedMessage? subscriptionMessage = null;
TaskCompletionSource<ProjectCommentsUpdatedMessage> tcs = new();
using var sub = Sut.CreateProjectCommentsUpdatedSubscription(new(_testProject.id, resourceIdString));
sub.Listeners += (_, message) => subscriptionMessage = message;
sub.Listeners += (_, message) => tcs.SetResult(message);
await Task.Delay(WAIT_PERIOD); // Give time to subscription to be setup
var created = await Fixtures.CreateComment(_testUser, _testProject.id, _testModel.id, _testVersion.id);
await Task.Delay(WAIT_PERIOD); // Give time for subscription to be triggered
var subscriptionMessage = await tcs.Task;
subscriptionMessage.Should().NotBeNull();
subscriptionMessage!.id.Should().Be(created.id);
subscriptionMessage.id.Should().Be(created.id);
subscriptionMessage.type.Should().Be(ProjectCommentsUpdatedMessageType.CREATED);
subscriptionMessage.comment.Should().NotBeNull();
}
@@ -1,85 +0,0 @@
using FluentAssertions;
using GraphQL.Client.Http;
using Microsoft.Extensions.DependencyInjection;
using Speckle.Sdk.Api.GraphQL.Models;
using Speckle.Sdk.Credentials;
namespace Speckle.Sdk.Tests.Integration.Credentials;
public class UserServerInfoTests : IAsyncLifetime
{
private Account _acc;
public Task DisposeAsync() => Task.CompletedTask;
public async Task InitializeAsync()
{
_acc = await Fixtures.SeedUser();
}
[Fact]
public async Task IsFrontEnd2True()
{
ServerInfo result = await Fixtures
.ServiceProvider.GetRequiredService<IAccountManager>()
.GetServerInfo(new("https://app.speckle.systems/"));
result.Should().NotBeNull();
result.frontend2.Should().BeTrue();
}
[Fact]
public async Task GetServerInfo_ExpectFail_NoServer()
{
Uri serverUrl = new("http://invalidserver.local");
await FluentActions
.Invoking(async () =>
await Fixtures.ServiceProvider.GetRequiredService<IAccountManager>().GetServerInfo(serverUrl)
)
.Should()
.ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task GetUserInfo()
{
Uri serverUrl = new(_acc.serverInfo.url);
UserInfo result = await Fixtures
.ServiceProvider.GetRequiredService<IAccountManager>()
.GetUserInfo(_acc.token, serverUrl);
result.id.Should().Be(_acc.userInfo.id);
result.name.Should().Be(_acc.userInfo.name);
result.email.Should().Be(_acc.userInfo.email);
result.company.Should().Be(_acc.userInfo.company);
result.avatar.Should().Be(_acc.userInfo.avatar);
}
[Fact]
public async Task GetUserInfo_ExpectFail_NoServer()
{
Uri serverUrl = new("http://invalidserver.local");
await FluentActions
.Invoking(async () =>
await Fixtures.ServiceProvider.GetRequiredService<IAccountManager>().GetUserInfo("", serverUrl)
)
.Should()
.ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task GetUserInfo_ExpectFail_NoUser()
{
Uri serverUrl = new(_acc.serverInfo.url);
await FluentActions
.Invoking(async () =>
await Fixtures
.ServiceProvider.GetRequiredService<IAccountManager>()
.GetUserInfo("Bearer 08913c3c1e7ac65d779d1e1f11b942a44ad9672ca9", serverUrl)
)
.Should()
.ThrowAsync<GraphQLHttpRequestException>();
}
}