[FA-misc] Fix issues with novel imports #63

Merged
conco merged 2 commits from epic/FA-misc_MassTransit into master 2026-01-30 17:55:41 +00:00
11 changed files with 86 additions and 23 deletions

View File

@@ -25,8 +25,6 @@ jobs:
dockerfile: FictionArchive.Service.FileService/Dockerfile
- name: scheduler-service
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
- name: authentication-service
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
- name: usernoveldata-service
dockerfile: FictionArchive.Service.UserNovelDataService/Dockerfile
steps:

View File

@@ -38,7 +38,8 @@ public class FileUploadRequestCreatedConsumer : IConsumer<IFileUploadRequestCrea
var putObjectRequest = new PutObjectRequest
{
BucketName = _s3Configuration.Bucket,
Key = message.FilePath
Key = message.FilePath,
UseChunkEncoding = false
};
using var memoryStream = new MemoryStream(message.FileData);

View File

@@ -38,7 +38,7 @@ public class NovelImportSagaTests
var importId = Guid.NewGuid();
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 0));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 0, false));
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
(await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue();
@@ -56,7 +56,7 @@ public class NovelImportSagaTests
var importId = Guid.NewGuid();
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2, false));
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
@@ -71,7 +71,7 @@ public class NovelImportSagaTests
var importId = Guid.NewGuid();
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 2, false));
await harness.Bus.Publish<IChapterPullCompleted>(new ChapterPullCompleted(importId, 1, 1));
await harness.Bus.Publish<IChapterPullCompleted>(new ChapterPullCompleted(importId, 2, 0));
await harness.Bus.Publish<IFileUploadRequestStatusUpdate>(new FileUploadRequestStatusUpdate(
@@ -81,6 +81,48 @@ public class NovelImportSagaTests
(await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue();
}
[Fact]
public async Task Should_transition_to_processing_when_cover_image_queued_with_no_chapters()
{
await using var provider = CreateTestProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
var importId = Guid.NewGuid();
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 0, true));
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
}
[Fact]
public async Task Should_complete_when_chapters_pulled_images_uploaded_and_cover_uploaded()
{
await using var provider = CreateTestProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
var importId = Guid.NewGuid();
await harness.Bus.Publish<INovelImportRequested>(new NovelImportRequested(importId, "https://example.com/novel"));
await harness.Bus.Publish<INovelMetadataImported>(new NovelMetadataImported(importId, 1, 1, true));
await harness.Bus.Publish<IChapterPullCompleted>(new ChapterPullCompleted(importId, 1, 0));
var sagaHarness = harness.GetSagaStateMachineHarness<NovelImportSaga, NovelImportSagaState>();
// Should still be processing - cover image not yet uploaded
(await sagaHarness.Exists(importId, x => x.Processing)).HasValue.Should().BeTrue();
// Upload cover image
await harness.Bus.Publish<IFileUploadRequestStatusUpdate>(new FileUploadRequestStatusUpdate(
importId, Guid.NewGuid(), RequestStatus.Success, "https://cdn.example.com/cover.jpg", null));
(await sagaHarness.Exists(importId, x => x.Completed)).HasValue.Should().BeTrue();
(await harness.Published.Any<INovelImportCompleted>(x =>
x.Context.Message.ImportId == importId && x.Context.Message.Success)).Should().BeTrue();
}
private ServiceProvider CreateTestProvider()
{
return new ServiceCollection()

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.NovelService.Contracts;
public record ImportNovelResult(Guid ImportId, string NovelUrl);

View File

@@ -16,7 +16,7 @@ public class Mutation
{
[Error<InvalidOperationException>]
[Authorize]
public async Task<NovelImportRequested> ImportNovel(string novelUrl, NovelUpdateService service)
public async Task<ImportNovelResult> ImportNovel(string novelUrl, NovelUpdateService service)
{
return await service.QueueNovelImport(novelUrl);
}

View File

@@ -57,9 +57,10 @@ public class NovelImportSaga : MassTransitStateMachine<NovelImportSagaState>
{
ctx.Saga.NovelId = ctx.Message.NovelId;
ctx.Saga.ExpectedChapters = ctx.Message.ChaptersPendingPull;
ctx.Saga.ExpectedImages += ctx.Message.CoverImageQueued ? 1 : 0;
})
.IfElse(
ctx => ctx.Saga.ExpectedChapters == 0,
ctx => ctx.Saga.ExpectedChapters == 0 && !ctx.Message.CoverImageQueued,
thenBinder => thenBinder
.Then(ctx => ctx.Saga.CompletedAt = _clock.GetCurrentInstant())
.TransitionTo(Completed)

View File

@@ -427,15 +427,18 @@ public class NovelUpdateService
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
.ToList();
var hasCoverToUpload = shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null;
// Publish metadata imported event for saga
await _publishEndpoint.Publish<INovelMetadataImported>(new NovelMetadataImported(
importId,
novel.Id,
chaptersNeedingPull.Count
chaptersNeedingPull.Count,
hasCoverToUpload
));
// Publish cover image event if needed
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
if (hasCoverToUpload)
{
await _publishEndpoint.Publish<IFileUploadRequestCreated>(new FileUploadRequestCreated(
importId,
@@ -568,7 +571,7 @@ public class NovelUpdateService
await _dbContext.SaveChangesAsync();
}
public async Task<NovelImportRequested> QueueNovelImport(string novelUrl)
public async Task<ImportNovelResult> QueueNovelImport(string novelUrl)
{
var importId = Guid.NewGuid();
var activeImport = new ActiveImport
@@ -590,7 +593,7 @@ public class NovelUpdateService
var importNovelRequestEvent = new NovelImportRequested(importId, novelUrl);
await _publishEndpoint.Publish<INovelImportRequested>(importNovelRequestEvent);
return importNovelRequestEvent;
return new ImportNovelResult(importId, novelUrl);
}
public async Task<ChapterPullRequested> QueueChapterPull(Guid importId, uint novelId, uint volumeId, uint chapterOrder)

View File

@@ -5,6 +5,7 @@ public interface INovelMetadataImported
Guid ImportId { get; }
uint NovelId { get; }
int ChaptersPendingPull { get; }
bool CoverImageQueued { get; }
}
public record NovelMetadataImported(Guid ImportId, uint NovelId, int ChaptersPendingPull) : INovelMetadataImported;
public record NovelMetadataImported(Guid ImportId, uint NovelId, int ChaptersPendingPull, bool CoverImageQueued) : INovelMetadataImported;

View File

@@ -39,7 +39,13 @@
return;
}
if (result.data?.importNovel?.novelUpdateRequestedEvent) {
const mutationErrors = result.data?.importNovel?.errors;
if (mutationErrors && mutationErrors.length > 0) {
error = mutationErrors[0].message;
return;
}
if (result.data?.importNovel?.importNovelResult) {
success = true;
setTimeout(() => {
handleClose();

View File

@@ -200,12 +200,20 @@ export type ImageDtoSortInput = {
newPath?: InputMaybe<SortEnumType>;
};
export type ImportNovelError = InvalidOperationError;
export type ImportNovelInput = {
novelUrl: Scalars['String']['input'];
};
export type ImportNovelPayload = {
novelImportRequested: Maybe<NovelImportRequested>;
errors: Maybe<Array<ImportNovelError>>;
importNovelResult: Maybe<ImportNovelResult>;
};
export type ImportNovelResult = {
importId: Scalars['UUID']['output'];
novelUrl: Scalars['String']['output'];
};
export type InstantFilterInput = {
@@ -463,11 +471,6 @@ export type NovelDtoSortInput = {
url?: InputMaybe<SortEnumType>;
};
export type NovelImportRequested = {
importId: Scalars['UUID']['output'];
novelUrl: Scalars['String']['output'];
};
export const NovelStatus = {
Abandoned: 'ABANDONED',
Completed: 'COMPLETED',
@@ -1013,7 +1016,7 @@ export type ImportNovelMutationVariables = Exact<{
}>;
export type ImportNovelMutation = { importNovel: { novelImportRequested: { importId: any, novelUrl: string } | null } };
export type ImportNovelMutation = { importNovel: { importNovelResult: { importId: any, novelUrl: string } | null, errors: Array<{ message: string }> | null } };
export type InviteUserMutationVariables = Exact<{
input: InviteUserInput;
@@ -1117,7 +1120,7 @@ export const AddToReadingListDocument = {"kind":"Document","definitions":[{"kind
export const CreateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CreateReadingListMutation, CreateReadingListMutationVariables>;
export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteNovelMutation, DeleteNovelMutationVariables>;
export const DeleteReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteReadingListMutation, DeleteReadingListMutationVariables>;
export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelImportRequested"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importId"}},{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ImportNovelMutation, ImportNovelMutationVariables>;
export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovelResult"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importId"}},{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<ImportNovelMutation, ImportNovelMutationVariables>;
export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<InviteUserMutation, InviteUserMutationVariables>;
export const RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<RemoveBookmarkMutation, RemoveBookmarkMutationVariables>;
export const RemoveFromReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveFromReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromReadingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<RemoveFromReadingListMutation, RemoveFromReadingListMutationVariables>;

View File

@@ -1,8 +1,13 @@
mutation ImportNovel($input: ImportNovelInput!) {
importNovel(input: $input) {
novelImportRequested {
importNovelResult {
importId
novelUrl
}
errors {
... on InvalidOperationError {
message
}
}
}
}