Skip to content

Commit e69e7e6

Browse files
Add CosmosDb test project and update repository methods
Added a new test project `CommunityToolkit.Datasync.Server.CosmosDb.Test` to the solution `Datasync.Toolkit.sln` with necessary configurations and references. Introduced `CosmosDbRepository_Tests`, `CosmosDbTestOptions`, and `CosmosDbMovie` classes for testing purposes. Modified `CosmosTableRepository` to improve method behaviors: `GetEntityAsync` now returns `null` if not found, `CreateAsync` simplified, `DeleteAsync` and `ReplaceAsync` updated for conditional operations, and `ReadAsync` now throws `HttpException` if entity is not found. Added using directives and license headers to new files.
1 parent 67b74a4 commit e69e7e6

File tree

6 files changed

+273
-31
lines changed

6 files changed

+273
-31
lines changed

Datasync.Toolkit.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.S
7272
EndProject
7373
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB.Test", "tests\CommunityToolkit.Datasync.Server.MongoDB.Test\CommunityToolkit.Datasync.Server.MongoDB.Test.csproj", "{4FC45D20-0BA9-484B-9040-641687659AF6}"
7474
EndProject
75+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb.Test", "tests\CommunityToolkit.Datasync.Server.CosmosDb.Test\CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj", "{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}"
76+
EndProject
7577
Global
7678
GlobalSection(SolutionConfigurationPlatforms) = preSolution
7779
Debug|Any CPU = Debug|Any CPU
@@ -170,6 +172,10 @@ Global
170172
{60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
171173
{60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
172174
{60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Release|Any CPU.Build.0 = Release|Any CPU
175+
{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
176+
{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
177+
{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
178+
{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Release|Any CPU.Build.0 = Release|Any CPU
173179
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
174180
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
175181
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -206,6 +212,7 @@ Global
206212
{A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2}
207213
{D9356867-0A30-4B17-BD4C-0F7EF70984C6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
208214
{60C73E92-9A45-4EE6-8DCF-48206CD0E5FE} = {75F709FD-8CC2-4558-A802-FE57086167C2}
215+
{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
209216
{DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
210217
{4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
211218
EndGlobalSection

src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Azure.Cosmos.Linq;
1010
using System.Diagnostics.CodeAnalysis;
1111
using System.Net;
12+
using System.Text;
1213

1314
namespace CommunityToolkit.Datasync.Server.CosmosDb;
1415

@@ -63,8 +64,17 @@ public CosmosTableRepository(CosmosClient client, ICosmosTableOptions<TEntity> o
6364
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
6465
/// <returns>A task that returns an untracked version of the entity when complete.</returns>
6566
/// <exception cref="CosmosException">Thrown if an error in the backend occurs.</exception>
66-
protected async Task<TEntity> GetEntityAsync(string entityId, PartitionKey partitionKey, CancellationToken cancellationToken = default)
67-
=> await Container.ReadItemAsync<TEntity>(entityId, partitionKey, cancellationToken: cancellationToken);
67+
protected async Task<TEntity?> GetEntityAsync(string entityId, PartitionKey partitionKey, CancellationToken cancellationToken = default)
68+
{
69+
try
70+
{
71+
return await Container.ReadItemAsync<TEntity>(entityId, partitionKey, cancellationToken: cancellationToken);
72+
}
73+
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
74+
{
75+
return null;
76+
}
77+
}
6878

6979
/// <summary>
7080
/// Updates the managed properties for this entity if required.
@@ -118,18 +128,6 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can
118128

119129
await WrapExceptionAsync(id, partitionKey, async () =>
120130
{
121-
try
122-
{
123-
TEntity? existingEntity = await Container.ReadItemAsync<TEntity>(id, partitionKey, cancellationToken: cancellationToken);
124-
if (existingEntity is not null)
125-
{
126-
throw new HttpException((int)HttpStatusCode.Conflict) { Payload = existingEntity };
127-
}
128-
}
129-
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
130-
{
131-
}
132-
133131
UpdateManagedProperties(entity);
134132

135133
ItemResponse<TEntity> response = await Container.CreateItemAsync(entity, partitionKey, cancellationToken: cancellationToken);
@@ -152,15 +150,17 @@ public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, Ca
152150

153151
await WrapExceptionAsync(id, partitionKey, async () =>
154152
{
155-
TEntity storedEntity = await Container.ReadItemAsync<TEntity>(entityId, partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false)
156-
?? throw new HttpException((int)HttpStatusCode.NotFound);
153+
ItemRequestOptions? requestOptions = null;
157154

158-
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
155+
if (version?.Length > 0)
159156
{
160-
throw new HttpException((int)HttpStatusCode.PreconditionFailed) { Payload = await GetEntityAsync(entityId, partitionKey, cancellationToken).ConfigureAwait(false) };
157+
requestOptions = new ItemRequestOptions()
158+
{
159+
IfMatchEtag = Encoding.UTF8.GetString(version)
160+
};
161161
}
162162

163-
_ = await Container.DeleteItemAsync<TEntity>(entityId, partitionKey, cancellationToken: cancellationToken);
163+
_ = await Container.DeleteItemAsync<TEntity>(entityId, partitionKey, requestOptions, cancellationToken);
164164

165165
}, cancellationToken).ConfigureAwait(false);
166166
}
@@ -175,11 +175,8 @@ public virtual async ValueTask<TEntity> ReadAsync(string id, CancellationToken c
175175

176176
string entityId = Options.ParsePartitionKey(id, out PartitionKey partitionKey);
177177

178-
ItemResponse<TEntity> response = await Container.ReadItemAsync<TEntity>(entityId, partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
179-
180-
//?? throw new HttpException((int)HttpStatusCode.NotFound);
181-
182-
return response.Resource;
178+
return await GetEntityAsync(entityId, partitionKey, cancellationToken).ConfigureAwait(false) ??
179+
throw new HttpException((int)HttpStatusCode.NotFound, $"Entity with id {id} not found");
183180
}
184181

185182
/// <inheritdoc />
@@ -194,17 +191,19 @@ public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = nu
194191

195192
await WrapExceptionAsync(id, partitionKey, async () =>
196193
{
197-
TEntity storedEntity = await Container.ReadItemAsync<TEntity>(id, partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false)
198-
?? throw new HttpException((int)HttpStatusCode.NotFound);
194+
UpdateManagedProperties(entity);
195+
196+
ItemRequestOptions? requestOptions = null;
199197

200-
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
198+
if (version?.Length > 0)
201199
{
202-
throw new HttpException((int)HttpStatusCode.PreconditionFailed) { Payload = await GetEntityAsync(id, partitionKey, cancellationToken).ConfigureAwait(false) };
200+
requestOptions = new ItemRequestOptions()
201+
{
202+
IfMatchEtag = Encoding.UTF8.GetString(version)
203+
};
203204
}
204205

205-
UpdateManagedProperties(entity);
206-
207-
_ = await Container.ReplaceItemAsync(entity, id, partitionKey, cancellationToken: cancellationToken);
206+
_ = await Container.ReplaceItemAsync(entity, id, partitionKey, requestOptions, cancellationToken);
208207

209208
}, cancellationToken).ConfigureAwait(false);
210209
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<IsPackable>false</IsPackable>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="coverlet.msbuild" />
10+
<PackageReference Include="coverlet.collector" />
11+
<PackageReference Include="xunit.runner.visualstudio" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.CosmosDb\CommunityToolkit.Datasync.Server.CosmosDb.csproj" />
16+
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<Using Include="Xunit" />
21+
</ItemGroup>
22+
23+
</Project>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.Abstractions.Json;
6+
using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models;
7+
using CommunityToolkit.Datasync.TestCommon;
8+
using CommunityToolkit.Datasync.TestCommon.Databases;
9+
using Microsoft.Azure.Cosmos;
10+
using Microsoft.Azure.Cosmos.Linq;
11+
using System.Collections.ObjectModel;
12+
using System.Net;
13+
using System.Text.Json;
14+
using System.Text.Json.Serialization;
15+
16+
namespace CommunityToolkit.Datasync.Server.CosmosDb.Test;
17+
18+
[ExcludeFromCodeCoverage]
19+
public class CosmosDbRepository_Tests : RepositoryTests<CosmosDbMovie>, IDisposable, IAsyncLifetime
20+
{
21+
#region Setup
22+
private readonly Random random = new();
23+
private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING");
24+
private readonly List<CosmosDbMovie> movies = [];
25+
26+
private CosmosClient _client;
27+
private Container _container;
28+
private CosmosTableRepository<CosmosDbMovie> _repository;
29+
30+
public void Dispose()
31+
{
32+
Dispose(true);
33+
GC.SuppressFinalize(this);
34+
}
35+
36+
protected virtual void Dispose(bool disposing)
37+
{
38+
if (disposing)
39+
{
40+
this._client?.Dispose();
41+
}
42+
}
43+
44+
override protected bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);
45+
protected override async Task<CosmosDbMovie> GetEntityAsync(string id)
46+
{
47+
try
48+
{
49+
return await this._container.ReadItemAsync<CosmosDbMovie>(id, new PartitionKey(id));
50+
}
51+
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
52+
{
53+
return null;
54+
}
55+
}
56+
57+
protected override async Task<int> GetEntityCountAsync()
58+
{
59+
return await this._container.GetItemLinqQueryable<CosmosDbMovie>().CountAsync();
60+
}
61+
62+
protected override Task<IRepository<CosmosDbMovie>> GetPopulatedRepositoryAsync()
63+
{
64+
return Task.FromResult<IRepository<CosmosDbMovie>>(this._repository);
65+
}
66+
67+
protected override Task<string> GetRandomEntityIdAsync(bool exists)
68+
{
69+
return Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString());
70+
}
71+
72+
public async Task InitializeAsync()
73+
{
74+
if (!string.IsNullOrEmpty(this.connectionString))
75+
{
76+
this._client = new CosmosClient(
77+
this.connectionString,
78+
new CosmosClientOptions()
79+
{
80+
UseSystemTextJsonSerializerWithOptions = new(JsonSerializerDefaults.Web)
81+
{
82+
AllowTrailingCommas = true,
83+
Converters =
84+
{
85+
new JsonStringEnumConverter(),
86+
new DateTimeOffsetConverter(),
87+
new DateTimeConverter(),
88+
new TimeOnlyConverter(),
89+
new SpatialGeoJsonConverter()
90+
},
91+
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
92+
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
93+
IgnoreReadOnlyFields = true,
94+
IgnoreReadOnlyProperties = true,
95+
IncludeFields = true,
96+
NumberHandling = JsonNumberHandling.AllowReadingFromString,
97+
PropertyNameCaseInsensitive = true,
98+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
99+
ReadCommentHandling = JsonCommentHandling.Skip
100+
}
101+
});
102+
103+
Database database = await this._client.CreateDatabaseIfNotExistsAsync("Movies");
104+
105+
this._container = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("Movies", "/id")
106+
{
107+
IndexingPolicy = new IndexingPolicy()
108+
{
109+
CompositeIndexes =
110+
{
111+
new Collection<CompositePath>()
112+
{
113+
new() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending },
114+
new() { Path = "/id", Order = CompositePathSortOrder.Ascending }
115+
}
116+
}
117+
}
118+
});
119+
120+
foreach (CosmosDbMovie movie in TestCommon.TestData.Movies.OfType<CosmosDbMovie>())
121+
{
122+
movie.UpdatedAt = DateTimeOffset.UtcNow;
123+
movie.Version = Guid.NewGuid().ToByteArray();
124+
_ = await this._container.CreateItemAsync(movie, new PartitionKey(movie.Id));
125+
this.movies.Add(movie);
126+
}
127+
128+
this._repository = new CosmosTableRepository<CosmosDbMovie>(
129+
this._client,
130+
new CosmosDbTestOptions("Movies", "Movies")
131+
);
132+
133+
}
134+
}
135+
136+
public async Task DisposeAsync()
137+
{
138+
if (this._client != null)
139+
{
140+
try
141+
{
142+
await this._client.GetDatabase("Movies").DeleteAsync();
143+
}
144+
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
145+
{
146+
// Ignore
147+
}
148+
}
149+
}
150+
#endregion
151+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.CosmosDb.Configuration;
6+
using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models;
7+
using Microsoft.Azure.Cosmos;
8+
using System;
9+
using System.Collections.Generic;
10+
using System.Linq;
11+
using System.Linq.Expressions;
12+
using System.Text;
13+
using System.Threading.Tasks;
14+
15+
namespace CommunityToolkit.Datasync.Server.CosmosDb.Test;
16+
internal class CosmosDbTestOptions : CosmosTableOptions<CosmosDbMovie>
17+
{
18+
public CosmosDbTestOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp)
19+
{
20+
}
21+
22+
public override string GetPartitionKey(CosmosDbMovie entity, out PartitionKey partitionKey)
23+
{
24+
partitionKey = new PartitionKey(entity.Id);
25+
26+
return entity.Id;
27+
}
28+
29+
public override string ParsePartitionKey(string id, out PartitionKey partitionKey)
30+
{
31+
partitionKey = new PartitionKey(id);
32+
return id;
33+
}
34+
35+
public override Expression<Func<CosmosDbMovie, bool>> QueryablePredicate() => (_) => true;
36+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.TestCommon.Models;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Text;
10+
using System.Threading.Tasks;
11+
12+
namespace CommunityToolkit.Datasync.Server.CosmosDb.Test.Models;
13+
public class CosmosDbMovie : CosmosTableData, IMovie
14+
{
15+
public bool BestPictureWinner { get; set; }
16+
17+
public int Duration { get; set; }
18+
19+
public MovieRating Rating { get; set; }
20+
21+
public DateOnly ReleaseDate { get; set; }
22+
23+
public string Title { get; set; }
24+
25+
public int Year { get; set; }
26+
}

0 commit comments

Comments
 (0)