Skip to content

Commit e767624

Browse files
committed
update resource changes to use either Edit or Create change where relevant. Update db model snapshot test
add remote resources support. add method to upload all pending local resources, and call from SyncWithResourceUpload helper method
1 parent 4200fa1 commit e767624

16 files changed

+527
-6
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace SIL.Harmony.Core;
2+
3+
public class EntityNotFoundException(string message) : Exception(message);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace SIL.Harmony.Core;
2+
3+
/// <summary>
4+
/// interface to facilitate downloading of resources, typically implemented in application code
5+
/// the remote Id is opaque to the CRDT lib and could be a URL or some other identifier provided by the backend
6+
/// the local path returned for the application code to use as required, it could be a URL if needed also.
7+
/// </summary>
8+
public interface IResourceService
9+
{
10+
/// <summary>
11+
/// instructs application code to download a resource from the remote server
12+
/// the service is responsible for downloading the resource and returning the local path
13+
/// </summary>
14+
/// <param name="remoteId">ID used to identify the remote resource, could be a URL</param>
15+
/// <param name="localResourceCachePath">path defined by the CRDT config where the resource should be stored</param>
16+
/// <returns>download result containing the path to the downloaded file, this is stored in the local db and not synced</returns>
17+
Task<DownloadResult> DownloadResource(string remoteId, string localResourceCachePath);
18+
/// <summary>
19+
/// upload a resource to the remote server
20+
/// </summary>
21+
/// <param name="localPath">full path to the resource on the local machine</param>
22+
/// <returns>an upload result with the remote id, the id will be stored and transmitted to other clients so they can also download the resource</returns>
23+
Task<UploadResult> UploadResource(string localPath);
24+
}
25+
26+
public record DownloadResult(string LocalPath);
27+
public record UploadResult(string RemoteId);

src/SIL.Harmony.Tests/DbContextTests.VerifyModel.verified.txt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,40 @@
8181
Relational:TableName: Snapshots
8282
Relational:ViewName:
8383
Relational:ViewSchema:
84+
EntityType: LocalResource
85+
Properties:
86+
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
87+
LocalPath (string) Required
88+
Keys:
89+
Id PK
90+
Annotations:
91+
DiscriminatorProperty:
92+
Relational:FunctionName:
93+
Relational:Schema:
94+
Relational:SqlQuery:
95+
Relational:TableName: LocalResource
96+
Relational:ViewName:
97+
Relational:ViewSchema:
98+
EntityType: RemoteResource
99+
Properties:
100+
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
101+
DeletedAt (DateTimeOffset?)
102+
RemoteId (string)
103+
SnapshotId (no field, Guid?) Shadow FK Index
104+
Keys:
105+
Id PK
106+
Foreign keys:
107+
RemoteResource {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull
108+
Indexes:
109+
SnapshotId Unique
110+
Annotations:
111+
DiscriminatorProperty:
112+
Relational:FunctionName:
113+
Relational:Schema:
114+
Relational:SqlQuery:
115+
Relational:TableName: RemoteResource
116+
Relational:ViewName:
117+
Relational:ViewSchema:
84118
EntityType: Definition
85119
Properties:
86120
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using System.Runtime.CompilerServices;
2+
using SIL.Harmony.Resource;
3+
4+
namespace SIL.Harmony.Tests.ResourceTests;
5+
6+
public class RemoteResourcesTests : DataModelTestBase
7+
{
8+
private RemoteServiceMock _remoteServiceMock = new();
9+
10+
public RemoteResourcesTests()
11+
{
12+
}
13+
14+
private string CreateFile(string contents, [CallerMemberName] string fileName = "")
15+
{
16+
var filePath = Path.GetFullPath(fileName + ".txt");
17+
File.WriteAllText(filePath, contents);
18+
return filePath;
19+
}
20+
21+
private async Task<(Guid resourceId, string remoteId)> SetupRemoteResource(string fileContents)
22+
{
23+
var remoteId = _remoteServiceMock.CreateRemoteResource(fileContents);
24+
var resourceId = Guid.NewGuid();
25+
await DataModel.AddChange(_localClientId, new CreateRemoteResourceChange(resourceId, remoteId));
26+
return (resourceId, remoteId);
27+
}
28+
29+
private async Task<(Guid resourceId, string localPath)> SetupLocalFile(string contents, [CallerMemberName] string fileName = "")
30+
{
31+
var file = CreateFile(contents, fileName);
32+
//because resource service is null the file is not uploaded
33+
var resourceId = await DataModel.AddLocalResource(file, _localClientId, resourceService: null);
34+
return (resourceId, file);
35+
}
36+
37+
[Fact]
38+
public async Task CreatingAResourceResultsInPendingLocalResources()
39+
{
40+
var (_, file) = await SetupLocalFile("contents");
41+
42+
//act
43+
var pending = await DataModel.ListResourcesPendingUpload();
44+
45+
46+
pending.Should().ContainSingle().Which.LocalPath.Should().Be(file);
47+
}
48+
49+
[Fact]
50+
public async Task ResourcesNotLocalShouldShowUpAsNotDownloaded()
51+
{
52+
var (resourceId, remoteId) = await SetupRemoteResource("test");
53+
54+
//act
55+
var pending = await DataModel.ListResourcesPendingDownload();
56+
57+
58+
var remoteResource = pending.Should().ContainSingle().Subject;
59+
remoteResource.RemoteId.Should().Be(remoteId);
60+
remoteResource.Id.Should().Be(resourceId);
61+
}
62+
63+
[Fact]
64+
public async Task CanUploadFileToRemote()
65+
{
66+
var fileContents = "resource";
67+
var localFile = CreateFile(fileContents);
68+
69+
//act
70+
var resourceId =
71+
await DataModel.AddLocalResource(localFile, _localClientId, resourceService: _remoteServiceMock);
72+
73+
74+
var resource = await DataModel.GetLatest<RemoteResource>(resourceId);
75+
ArgumentNullException.ThrowIfNull(resource);
76+
ArgumentNullException.ThrowIfNull(resource.RemoteId);
77+
_remoteServiceMock.ReadFile(resource.RemoteId).Should().Be(fileContents);
78+
var pendingUpload = await DataModel.ListResourcesPendingUpload();
79+
pendingUpload.Should().BeEmpty();
80+
}
81+
82+
[Fact]
83+
public async Task WillUploadMultiplePendingLocalFilesAtOnce()
84+
{
85+
await SetupLocalFile("file1", "file1");
86+
await SetupLocalFile("file2", "file2");
87+
88+
//act
89+
await DataModel.UploadPendingResources(_localClientId, _remoteServiceMock);
90+
91+
92+
_remoteServiceMock.ListFiles()
93+
.Select(Path.GetFileName)
94+
.Should()
95+
.Contain(["file1.txt", "file2.txt"]);
96+
}
97+
98+
[Fact]
99+
public async Task CanDownloadFileFromRemote()
100+
{
101+
var fileContents = "resource";
102+
var (resourceId, _) = await SetupRemoteResource(fileContents);
103+
104+
//act
105+
var localResource = await DataModel.DownloadResource(resourceId, _remoteServiceMock);
106+
107+
108+
localResource.Id.Should().Be(resourceId);
109+
var actualFileContents = await File.ReadAllTextAsync(localResource.LocalPath);
110+
actualFileContents.Should().Be(fileContents);
111+
var pendingDownloads = await DataModel.ListResourcesPendingDownload();
112+
pendingDownloads.Should().BeEmpty();
113+
}
114+
115+
[Fact]
116+
public async Task CanGetALocalResourceGivenAnId()
117+
{
118+
var file = CreateFile("resource");
119+
//because resource service is null the file is not uploaded
120+
var resourceId = await DataModel.AddLocalResource(file, _localClientId, resourceService: null);
121+
122+
//act
123+
var localResource = await DataModel.GetLocalResource(resourceId);
124+
125+
126+
localResource.Should().NotBeNull();
127+
localResource!.LocalPath.Should().Be(file);
128+
}
129+
130+
[Fact]
131+
public async Task LocalResourceIsNullIfNotDownloaded()
132+
{
133+
var (resourceId, _) = await SetupRemoteResource("test");
134+
var localResource = await DataModel.GetLocalResource(resourceId);
135+
localResource.Should().BeNull();
136+
}
137+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using SIL.Harmony.Core;
2+
3+
namespace SIL.Harmony.Tests.ResourceTests;
4+
5+
public class RemoteServiceMock : IResourceService
6+
{
7+
public static readonly string RemotePath = Directory.CreateTempSubdirectory("RemoteServiceMock").FullName;
8+
9+
/// <summary>
10+
/// directly creates a remote resource
11+
/// </summary>
12+
/// <returns>the remote id</returns>
13+
public string CreateRemoteResource(string contents)
14+
{
15+
var filePath = Path.Combine(RemotePath, Guid.NewGuid().ToString("N") + ".txt");
16+
File.WriteAllText(filePath, contents);
17+
return filePath;
18+
}
19+
20+
public Task<DownloadResult> DownloadResource(string remoteId, string localResourceCachePath)
21+
{
22+
var fileName = Path.GetFileName(remoteId);
23+
var localPath = Path.Combine(localResourceCachePath, fileName);
24+
Directory.CreateDirectory(localResourceCachePath);
25+
File.Copy(remoteId, localPath);
26+
return Task.FromResult(new DownloadResult(localPath));
27+
}
28+
29+
public Task<UploadResult> UploadResource(string localPath)
30+
{
31+
var remoteId = Path.Combine(RemotePath, Path.GetFileName(localPath));
32+
File.Copy(localPath, remoteId);
33+
return Task.FromResult(new UploadResult(remoteId));
34+
}
35+
36+
public string ReadFile(string remoteId)
37+
{
38+
return File.ReadAllText(remoteId);
39+
}
40+
41+
public IEnumerable<string> ListFiles()
42+
{
43+
return Directory.GetFiles(RemotePath);
44+
}
45+
}

src/SIL.Harmony/Changes/EditChange.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace SIL.Harmony.Changes;
1+
namespace SIL.Harmony.Changes;
22

33
public abstract class EditChange<T>(Guid entityId) : Change<T>(entityId)
44
where T : class

src/SIL.Harmony/CrdtConfig.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
using System.Text.Json;
1+
using System.Text.Json;
22
using System.Text.Json.Serialization.Metadata;
33
using Microsoft.EntityFrameworkCore;
44
using Microsoft.EntityFrameworkCore.Metadata.Builders;
55
using SIL.Harmony.Adapters;
66
using SIL.Harmony.Changes;
77
using SIL.Harmony.Db;
88
using SIL.Harmony.Entities;
9+
using SIL.Harmony.Resource;
910

1011
namespace SIL.Harmony;
1112

@@ -67,6 +68,25 @@ private void JsonTypeModifier(JsonTypeInfo typeInfo)
6768
}
6869
}
6970
}
71+
72+
public bool RemoteResourcesEnabled { get; private set; }
73+
public string LocalResourceCachePath { get; set; } = Path.GetFullPath("./localResourceCache");
74+
public void AddRemoteResourceEntity(string? cachePath = null)
75+
{
76+
RemoteResourcesEnabled = true;
77+
LocalResourceCachePath = cachePath ?? LocalResourceCachePath;
78+
ObjectTypeListBuilder.Add<RemoteResource>();
79+
ChangeTypeListBuilder.Add<RemoteResourceUploadedChange>();
80+
ChangeTypeListBuilder.Add<CreateRemoteResourceChange>();
81+
ChangeTypeListBuilder.Add<CreateRemoteResourcePendingUploadChange>();
82+
ChangeTypeListBuilder.Add<DeleteChange<RemoteResource>>();
83+
ObjectTypeListBuilder.AddDbModelConfig(builder =>
84+
{
85+
var entity = builder.Entity<LocalResource>();
86+
entity.HasKey(lr => lr.Id);
87+
entity.Property(lr => lr.LocalPath);
88+
});
89+
}
7090
}
7191

7292
public class ChangeTypeListBuilder

0 commit comments

Comments
 (0)