diff --git a/api/QuestionsApi.Tests/QuestionStoreTests.cs b/api/QuestionsApi.Tests/QuestionStoreTests.cs new file mode 100644 index 0000000..88146f8 --- /dev/null +++ b/api/QuestionsApi.Tests/QuestionStoreTests.cs @@ -0,0 +1,49 @@ +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class QuestionStoreTests +{ + [Fact] + public void ApplyMediaReviewGate_FlagsUnflaggedQuestionWithMedia() + { + var question = new Question { Media = ["asset-1"] }; + + QuestionStore.ApplyMediaReviewGate(question); + + Assert.NotNull(question.FlaggedForReviewUtc); + Assert.Equal("system", question.FlaggedByUserId); + Assert.False(string.IsNullOrWhiteSpace(question.FlagReason)); + } + + [Fact] + public void ApplyMediaReviewGate_LeavesMediaFreeQuestionsAlone() + { + var question = new Question { Media = [] }; + + QuestionStore.ApplyMediaReviewGate(question); + + Assert.Null(question.FlaggedForReviewUtc); + } + + [Fact] + public void ApplyMediaReviewGate_DoesNotOverwriteAnExistingFlag() + { + var flaggedAt = DateTime.UtcNow.AddDays(-1); + var question = new Question + { + Media = ["asset-1"], + FlaggedForReviewUtc = flaggedAt, + FlagReason = "Reported by student", + FlaggedByUserId = "user-42" + }; + + QuestionStore.ApplyMediaReviewGate(question); + + Assert.Equal(flaggedAt, question.FlaggedForReviewUtc); + Assert.Equal("Reported by student", question.FlagReason); + Assert.Equal("user-42", question.FlaggedByUserId); + } +} diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index 3e6b74e..9ade674 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -6,6 +6,9 @@ namespace QuestionsApi.Services; public class QuestionStore { + private const string MediaReviewReason = "Contains media - pending review"; + private const string MediaReviewFlaggedBy = "system"; + private readonly IMongoCollection _col; public QuestionStore(MongoDatabaseProvider mongo) @@ -105,6 +108,7 @@ public class QuestionStore var question = FromRequest(req); question.CreatedUtc = DateTime.UtcNow; question.UpdatedUtc = DateTime.UtcNow; + ApplyMediaReviewGate(question); try { @@ -131,6 +135,7 @@ public class QuestionStore question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc; question.FlagReason = existing.FlagReason; question.FlaggedByUserId = existing.FlaggedByUserId; + ApplyMediaReviewGate(question); var result = await _col.ReplaceOneAsync(q => q.Id == id, question); return result.MatchedCount == 0 ? null : question; } @@ -158,6 +163,7 @@ public class QuestionStore question.Id = ObjectId.GenerateNewId().ToString(); question.CreatedUtc = DateTime.UtcNow; } + ApplyMediaReviewGate(question); await _col.ReplaceOneAsync(filter, question, new ReplaceOptions { IsUpsert = true }); count++; @@ -246,6 +252,18 @@ public class QuestionStore return (int)result.MatchedCount; } + // Questions with media must be reviewed before reaching students - gate any question that + // gains media and isn't already flagged, so newly created/imported/edited questions can't + // slip past the media-review requirement the way a one-time sweep alone would miss. + internal static void ApplyMediaReviewGate(Question question) + { + if (question.Media.Count == 0 || question.FlaggedForReviewUtc is not null) + return; + question.FlaggedForReviewUtc = DateTime.UtcNow; + question.FlagReason = MediaReviewReason; + question.FlaggedByUserId = MediaReviewFlaggedBy; + } + private static Question FromRequest(UpsertQuestionRequest req) => new() { ExternalId = req.ExternalId.Trim(),