diff --git a/Notesnook.API.Tests/Controllers/MonographsControllerTests.cs b/Notesnook.API.Tests/Controllers/MonographsControllerTests.cs new file mode 100644 index 0000000..e381010 --- /dev/null +++ b/Notesnook.API.Tests/Controllers/MonographsControllerTests.cs @@ -0,0 +1,822 @@ +using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; +using Moq; +using Notesnook.API.Controllers; +using Notesnook.API.Interfaces; +using Notesnook.API.Models; +using Notesnook.API.Tests.Helpers; +using Notesnook.API.Tests.TestData; + +namespace Notesnook.API.Tests.Controllers +{ + [TestClass] + public class MonographsControllerTests + { + private Mock _mockMonographRepository; + private Mock _mockSyncDeviceServiceWrapper; + private Mock _mockMessengerService; + private MonographsController _controller; + + [TestInitialize] + public void TestInitialize() + { + _mockMonographRepository = new Mock(); + _mockSyncDeviceServiceWrapper = new Mock(); + _mockMessengerService = new Mock(); + + _controller = new MonographsController( + _mockMonographRepository.Object, + _mockSyncDeviceServiceWrapper.Object, + _mockMessengerService.Object); + + ControllerTestHelper.SetupControllerContext( + _controller, + MonographTestData.TestUserId, + MonographTestData.TestJtiToken); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_ValidMonograph_PublishesSuccessfully() + { + var monograph = MonographTestData.CreateMonograph(); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync((Monograph?)null); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent != null && + m.EncryptedContent == null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(MonographTestData.TestUserId, monograph.ItemId, deviceId), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Once); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_NoUserId_ReturnsUnauthorized() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(); + var deviceId = MonographTestData.TestDeviceId; + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(UnauthorizedResult)); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_MonographAlreadyExists_ReturnsConflict() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(ConflictObjectResult)); + var conflictResult = (ConflictObjectResult)result; + Assert.AreEqual("This monograph is already published.", conflictResult.Value); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_ExistingMonographSoftDeleted_PublishesSuccessfully() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true, deleted: true); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent != null && + m.EncryptedContent == null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(MonographTestData.TestUserId, monograph.ItemId, deviceId), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Once); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_EncryptedMonographTooLarge_ReturnsBadRequest() + { + var monograph = MonographTestData.CreateLargeEncryptedMonograph(); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync((Monograph?)null); + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); + var badRequestResult = (BadRequestObjectResult)result; + Assert.AreEqual("Monograph is too big. Max allowed size is 15mb.", badRequestResult.Value); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_WithEncryptedContent_PublishesSuccessfully() + { + var monograph = MonographTestData.CreateEncryptedMonograph(); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync((Monograph?)null); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + + _mockMonographRepository + .Setup(x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent == null && + m.EncryptedContent != null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(MonographTestData.TestUserId, monograph.ItemId, deviceId), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Once); + } + + [TestCategory("PublishAsync")] + [TestMethod] + public async Task PublishAsync_WithNullDeviceId_SkipsSyncOperations() + { + var monograph = MonographTestData.CreateMonograph(); + string? deviceId = null; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync((Monograph?)null); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.PublishAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.PublishOrUpdateAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent != null && + m.EncryptedContent == null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Never); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_ValidMonograph_UpdatesSuccessfully() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + var mockUpdateResult = new Mock(); + mockUpdateResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.UpdateMonographAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockUpdateResult.Object); + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent != null && + m.EncryptedContent == null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(MonographTestData.TestUserId, monograph.ItemId, deviceId), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Once); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_NoUserId_ReturnsUnauthorized() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(); + var deviceId = MonographTestData.TestDeviceId; + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(UnauthorizedResult)); + _mockMonographRepository.Verify( + x => x.FindByUserAndItemAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_MonographNotFound_ReturnsNotFound() + { + var monograph = MonographTestData.CreateMonograph(); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync((Monograph?)null); + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(NotFoundResult)); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_MonographDeleted_ReturnsNotFound() + { + var monograph = MonographTestData.CreateMonograph(deleted: true, isExisting: true); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(NotFoundResult)); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_EncryptedMonographTooLarge_ReturnsBadRequest() + { + var monograph = MonographTestData.CreateLargeEncryptedMonograph(); + var deviceId = MonographTestData.TestDeviceId; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); + var badRequestResult = (BadRequestObjectResult)result; + Assert.AreEqual("Monograph is too big. Max allowed size is 15mb.", badRequestResult.Value); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_WithEncryptedContent_UpdatesSuccessfully() + { + var monograph = MonographTestData.CreateEncryptedMonograph(); + var deviceId = MonographTestData.TestDeviceId; + + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + var mockUpdateResult = new Mock(); + mockUpdateResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.UpdateMonographAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockUpdateResult.Object); + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent == null && + m.EncryptedContent != null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(MonographTestData.TestUserId, monograph.ItemId, deviceId), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Once); + } + + [TestCategory("UpdateAsync")] + [TestMethod] + public async Task UpdateAsync_WithNullDeviceId_SkipsSyncOperations() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true); + string? deviceId = null; + _mockMonographRepository + .Setup(x => x.FindByUserAndItemAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(monograph); + var mockUpdateResult = new Mock(); + mockUpdateResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.UpdateMonographAsync(MonographTestData.TestUserId, It.IsAny())) + .ReturnsAsync(mockUpdateResult.Object); + + var result = await _controller.UpdateAsync(deviceId, monograph); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + Assert.IsNotNull(((OkObjectResult)result).Value); + _mockMonographRepository.Verify( + x => x.UpdateMonographAsync(MonographTestData.TestUserId, It.Is(m => + m.UserId == MonographTestData.TestUserId && + m.Title == monograph.Title && + m.Content == monograph.Content && + m.Password == monograph.Password && + m.SelfDestruct == monograph.SelfDestruct && + m.CompressedContent != null && + m.EncryptedContent == null && + m.DatePublished > 0 + )), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Never); + } + + [TestCategory("GetUserMonographsAsync")] + [TestMethod] + public async Task GetUserMonographsAsync_ValidUser_ReturnsMonographIds() + { + var ids = MonographTestData.MonographIds(); + _mockMonographRepository + .Setup(x => x.GetUserMonographIdsAsync(MonographTestData.TestUserId)) + .ReturnsAsync(ids); + + var result = await _controller.GetUserMonographsAsync(); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + Assert.IsNotNull(okResult.Value); + Assert.AreEqual(ids, okResult.Value); + } + + [TestCategory("GetUserMonographsAsync")] + [TestMethod] + public async Task GetUserMonographsAsync_NoUserId_ReturnsUnauthorized() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var result = await _controller.GetUserMonographsAsync(); + + Assert.IsInstanceOfType(result, typeof(UnauthorizedResult)); + _mockMonographRepository.Verify( + x => x.GetUserMonographIdsAsync(It.IsAny()), + Times.Never); + } + + [TestCategory("GetMonographAsync")] + [TestMethod] + public async Task GetMonographAsync_ValidId_ReturnsMonograph() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true); + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.GetMonographAsync(monographId); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + Assert.IsNotNull(okResult.Value); + var returnedMonograph = (Monograph)okResult.Value; + Assert.AreEqual(monograph.ItemId, returnedMonograph.ItemId); + Assert.AreEqual(monograph.Title, returnedMonograph.Title); + Assert.AreEqual(monograph.UserId, returnedMonograph.UserId); + Assert.IsFalse(returnedMonograph.Deleted); + } + + [TestCategory("GetMonographAsync")] + [TestMethod] + public async Task GetMonographAsync_MonographNotFound_ReturnsNotFound() + { + var monographId = "non-existent-id"; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync((Monograph?)null); + + var result = await _controller.GetMonographAsync(monographId); + + Assert.IsInstanceOfType(result, typeof(NotFoundObjectResult)); + } + + [TestCategory("GetMonographAsync")] + [TestMethod] + public async Task GetMonographAsync_MonographDeleted_ReturnsNotFound() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(deleted: true, isExisting: true); + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.GetMonographAsync(monographId); + + Assert.IsInstanceOfType(result, typeof(NotFoundObjectResult)); + } + + [TestCategory("GetMonographAsync")] + [TestMethod] + public async Task GetMonographAsync_WithEncryptedContent_ReturnsMonograph() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateEncryptedMonograph(); + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.GetMonographAsync(monographId); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + Assert.IsNotNull(okResult.Value); + var returnedMonograph = (Monograph)okResult.Value; + Assert.AreEqual(monograph.ItemId, returnedMonograph.ItemId); + Assert.IsNotNull(returnedMonograph.EncryptedContent); + Assert.IsNull(returnedMonograph.Content); + } + + [TestCategory("GetMonographAsync")] + [TestMethod] + public async Task GetMonographAsync_ItemIdIsNull_SetsItemIdToId() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(isExisting: true); + monograph.ItemId = null; + var monographId = monograph.Id; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.GetMonographAsync(monographId); + + Assert.IsInstanceOfType(result, typeof(OkObjectResult)); + var okResult = (OkObjectResult)result; + Assert.IsNotNull(okResult.Value); + var returnedMonograph = (Monograph)okResult.Value; + Assert.AreEqual(monograph.Id, returnedMonograph.ItemId); + } + + [TestCategory("TrackView")] + [TestMethod] + public async Task TrackView_MonographNotFound_ReturnsSvgPixel() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monographId = "non-existent-id"; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync((Monograph?)null); + + var result = await _controller.TrackView(monographId); + + Assert.IsInstanceOfType(result, typeof(ContentResult)); + _mockMonographRepository.Verify( + x => x.SelfDestructAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("TrackView")] + [TestMethod] + public async Task TrackView_MonographDeleted_ReturnsSvgPixel() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(deleted: true, isExisting: true); + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.TrackView(monographId); + + Assert.IsInstanceOfType(result, typeof(ContentResult)); + _mockMonographRepository.Verify( + x => x.SelfDestructAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("TrackView")] + [TestMethod] + public async Task TrackView_NonSelfDestructMonograph_ReturnsSvgPixel() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(isExisting: true, selfDestruct: false); + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.TrackView(monographId); + + Assert.IsInstanceOfType(result, typeof(ContentResult)); + _mockMonographRepository.Verify( + x => x.SelfDestructAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("TrackView")] + [TestMethod] + public async Task TrackView_SelfDestructMonograph_DestroysMonographAndReturnsSvgPixel() + { + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + ControllerTestHelper.SetupUnauthenticatedControllerContext(_controller); + var monograph = MonographTestData.CreateMonograph(isExisting: true, selfDestruct: true); + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.SelfDestructAsync(monograph, monographId)) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.TrackView(monographId); + + Assert.IsInstanceOfType(result, typeof(ContentResult)); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(monograph.UserId, monographId, null), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", monograph.UserId, null, true), + Times.Once); + } + + [TestCategory("DeleteAsync")] + [TestMethod] + public async Task DeleteAsync_ValidMonograph_DeletesSuccessfully() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true); + var deviceId = MonographTestData.TestDeviceId; + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.SoftDeleteAsync(MonographTestData.TestUserId, monograph, monographId)) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.DeleteAsync(deviceId, monographId); + + Assert.IsInstanceOfType(result, typeof(OkResult)); + _mockMonographRepository.Verify( + x => x.SoftDeleteAsync(MonographTestData.TestUserId, monograph, monographId), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(MonographTestData.TestUserId, monographId, deviceId), + Times.Once); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync("Monographs updated", MonographTestData.TestUserId, MonographTestData.TestJtiToken, false), + Times.Once); + } + + [TestCategory("DeleteAsync")] + [TestMethod] + public async Task DeleteAsync_MonographNotFound_ReturnsNotFound() + { + var deviceId = MonographTestData.TestDeviceId; + var monographId = "non-existent-id"; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync((Monograph?)null); + + var result = await _controller.DeleteAsync(deviceId, monographId); + + Assert.IsInstanceOfType(result, typeof(NotFoundObjectResult)); + _mockMonographRepository.Verify( + x => x.SoftDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("DeleteAsync")] + [TestMethod] + public async Task DeleteAsync_MonographAlreadyDeleted_ReturnsNotFound() + { + var monograph = MonographTestData.CreateMonograph(deleted: true, isExisting: true); + var deviceId = MonographTestData.TestDeviceId; + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + + var result = await _controller.DeleteAsync(deviceId, monographId); + + Assert.IsInstanceOfType(result, typeof(NotFoundObjectResult)); + _mockMonographRepository.Verify( + x => x.SoftDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestCategory("DeleteAsync")] + [TestMethod] + public async Task DeleteAsync_WithNullDeviceId_SkipsSyncOperations() + { + var monograph = MonographTestData.CreateMonograph(isExisting: true); + string? deviceId = null; + var monographId = monograph.ItemId; + _mockMonographRepository + .Setup(x => x.FindByItemIdAsync(monographId)) + .ReturnsAsync(monograph); + var mockReplaceResult = new Mock(); + mockReplaceResult.Setup(x => x.IsAcknowledged).Returns(true); + _mockMonographRepository + .Setup(x => x.SoftDeleteAsync(MonographTestData.TestUserId, monograph, monographId)) + .ReturnsAsync(mockReplaceResult.Object); + + var result = await _controller.DeleteAsync(deviceId, monographId); + + Assert.IsInstanceOfType(result, typeof(OkResult)); + _mockMonographRepository.Verify( + x => x.SoftDeleteAsync(MonographTestData.TestUserId, monograph, monographId), + Times.Once); + _mockSyncDeviceServiceWrapper.Verify( + x => x.MarkMonographForSyncAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockMessengerService.Verify( + x => x.SendTriggerSyncEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/Notesnook.API.Tests/Helpers/ControllerTestHelper.cs b/Notesnook.API.Tests/Helpers/ControllerTestHelper.cs new file mode 100644 index 0000000..4a70bc8 --- /dev/null +++ b/Notesnook.API.Tests/Helpers/ControllerTestHelper.cs @@ -0,0 +1,46 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Notesnook.API.Controllers; + +namespace Notesnook.API.Tests.Helpers +{ + public static class ControllerTestHelper + { + public static void SetupControllerContext(MonographsController controller, string? userId = null, string? jti = null) + { + var claims = new List(); + + if (!string.IsNullOrEmpty(userId)) + { + claims.Add(new Claim("sub", userId)); + } + + if (!string.IsNullOrEmpty(jti)) + { + claims.Add(new Claim("jti", jti)); + } + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = principal + } + }; + } + + public static void SetupUnauthenticatedControllerContext(MonographsController controller) + { + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity()) + } + }; + } + } +} diff --git a/Notesnook.API.Tests/Notesnook.API.Tests.csproj b/Notesnook.API.Tests/Notesnook.API.Tests.csproj new file mode 100644 index 0000000..ddecd56 --- /dev/null +++ b/Notesnook.API.Tests/Notesnook.API.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/Notesnook.API.Tests/TestData/MonographTestData.cs b/Notesnook.API.Tests/TestData/MonographTestData.cs new file mode 100644 index 0000000..ecb0c05 --- /dev/null +++ b/Notesnook.API.Tests/TestData/MonographTestData.cs @@ -0,0 +1,69 @@ +using MongoDB.Bson; +using Notesnook.API.Models; + +namespace Notesnook.API.Tests.TestData +{ + public static class MonographTestData + { + public const string TestUserId = "test-user-123"; + public const string TestDeviceId = "test-device-456"; + public const string TestJtiToken = "test-jti-token"; + + public static Monograph CreateMonograph( + string? itemId = null, + string? userId = null, + bool deleted = false, + bool isExisting = false, + bool selfDestruct = false, + string? title = null) + { + return new Monograph + { + Id = ObjectId.GenerateNewId().ToString(), + ItemId = itemId ?? Guid.NewGuid().ToString(), + Title = title ?? "Test Monograph", + UserId = userId ?? TestUserId, + Content = "This is test content for the monograph.", + Deleted = deleted, + DatePublished = isExisting ? DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() : 0, + SelfDestruct = selfDestruct, + EncryptedContent = null, + CompressedContent = [1, 2, 3, 4, 5], + Password = null + }; + } + + public static Monograph CreateEncryptedMonograph() + { + var monograph = CreateMonograph(); + monograph.Content = null; + monograph.CompressedContent = null; + monograph.EncryptedContent = new EncryptedData + { + Cipher = "encrypted-test-content", + IV = "test-iv", + Salt = "test-salt" + }; + return monograph; + } + + public static Monograph CreateLargeEncryptedMonograph() + { + var monograph = CreateMonograph(); + monograph.Content = null; + monograph.CompressedContent = null; + monograph.EncryptedContent = new EncryptedData + { + Cipher = new string('*', 16 * 1024 * 1024), + IV = "test-iv", + Salt = "test-salt" + }; + return monograph; + } + + public static IEnumerable MonographIds() + { + return new List { "id1", "id2", "id3" }; + } + } +} diff --git a/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs b/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs index 3d2d1c1..5b4940d 100644 --- a/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs +++ b/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs @@ -42,7 +42,7 @@ public class SyncItemsRepositoryAccessor : ISyncItemsRepositoryAccessor public SyncItemsRepository Vaults { get; } public SyncItemsRepository Tags { get; } public Repository UsersSettings { get; } - public Repository Monographs { get; } + public IMonographRepository Monographs { get; } public SyncItemsRepositoryAccessor(IDbContext dbContext, @@ -71,7 +71,7 @@ public SyncItemsRepositoryAccessor(IDbContext dbContext, [FromKeyedServices(Collections.TagsKey)] IMongoCollection tags, - Repository usersSettings, Repository monographs) + Repository usersSettings, IMonographRepository monographs) { UsersSettings = usersSettings; Monographs = monographs; diff --git a/Notesnook.API/Controllers/MonographsController.cs b/Notesnook.API/Controllers/MonographsController.cs index 344e5dc..5c45859 100644 --- a/Notesnook.API/Controllers/MonographsController.cs +++ b/Notesnook.API/Controllers/MonographsController.cs @@ -18,21 +18,13 @@ You should have received a copy of the Affero GNU General Public License */ using System; -using System.Collections.Generic; -using System.Linq; using System.Security.Claims; -using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using MongoDB.Bson; -using MongoDB.Driver; +using Notesnook.API.Interfaces; using Notesnook.API.Models; -using Notesnook.API.Services; using Streetwriters.Common; -using Streetwriters.Common.Messages; -using Streetwriters.Data.Interfaces; -using Streetwriters.Data.Repositories; namespace Notesnook.API.Controllers { @@ -42,57 +34,15 @@ namespace Notesnook.API.Controllers public class MonographsController : ControllerBase { const string SVG_PIXEL = ""; - private Repository Monographs { get; set; } - private readonly IUnitOfWork unit; + private IMonographRepository Monographs { get; set; } + private readonly ISyncDeviceServiceWrapper syncDeviceServiceWrapper; + private readonly IMessengerService messengerService; private const int MAX_DOC_SIZE = 15 * 1024 * 1024; - public MonographsController(Repository monographs, IUnitOfWork unitOfWork) + public MonographsController(IMonographRepository monographs, ISyncDeviceServiceWrapper syncDeviceServiceWrapper, IMessengerService messengerService) { Monographs = monographs; - unit = unitOfWork; - } - - private static FilterDefinition CreateMonographFilter(string userId, Monograph monograph) - { - var userIdFilter = Builders.Filter.Eq("UserId", userId); - monograph.ItemId ??= monograph.Id; - return ObjectId.TryParse(monograph.ItemId, out ObjectId id) - ? Builders.Filter - .And(userIdFilter, - Builders.Filter.Or( - Builders.Filter.Eq("_id", id), Builders.Filter.Eq("ItemId", monograph.ItemId) - ) - ) - : Builders.Filter - .And(userIdFilter, - Builders.Filter.Eq("ItemId", monograph.ItemId) - ); - } - - private static FilterDefinition CreateMonographFilter(string itemId) - { - return ObjectId.TryParse(itemId, out ObjectId id) - ? Builders.Filter.Or( - Builders.Filter.Eq("_id", id), - Builders.Filter.Eq("ItemId", itemId)) - : Builders.Filter.Eq("ItemId", itemId); - } - - private async Task FindMonographAsync(string userId, Monograph monograph) - { - var result = await Monographs.Collection.FindAsync(CreateMonographFilter(userId, monograph), new FindOptions - { - Limit = 1 - }); - return await result.FirstOrDefaultAsync(); - } - - private async Task FindMonographAsync(string itemId) - { - var result = await Monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions - { - Limit = 1 - }); - return await result.FirstOrDefaultAsync(); + this.syncDeviceServiceWrapper = syncDeviceServiceWrapper; + this.messengerService = messengerService; } [HttpPost] @@ -103,7 +53,7 @@ public async Task PublishAsync([FromQuery] string deviceId, [From var userId = this.User.FindFirstValue("sub"); if (userId == null) return Unauthorized(); - var existingMonograph = await FindMonographAsync(userId, monograph); + var existingMonograph = await Monographs.FindByUserAndItemAsync(userId, monograph); if (existingMonograph != null && !existingMonograph.Deleted) { return base.Conflict("This monograph is already published."); @@ -122,13 +72,10 @@ public async Task PublishAsync([FromQuery] string deviceId, [From monograph.Id = existingMonograph?.Id; } monograph.Deleted = false; - await Monographs.Collection.ReplaceOneAsync( - CreateMonographFilter(userId, monograph), - monograph, - new ReplaceOptions { IsUpsert = true } - ); + await Monographs.PublishOrUpdateAsync(userId, monograph); - await MarkMonographForSyncAsync(monograph.ItemId ?? monograph.Id, deviceId); + var jti = this.User.FindFirstValue("jti"); + await MarkMonographForSyncAsync(userId, jti, monograph.ItemId ?? monograph.Id, deviceId); return Ok(new { @@ -151,7 +98,7 @@ public async Task UpdateAsync([FromQuery] string deviceId, [FromB var userId = this.User.FindFirstValue("sub"); if (userId == null) return Unauthorized(); - var existingMonograph = await FindMonographAsync(userId, monograph); + var existingMonograph = await Monographs.FindByUserAndItemAsync(userId, monograph); if (existingMonograph == null || existingMonograph.Deleted) { return NotFound(); @@ -166,19 +113,11 @@ public async Task UpdateAsync([FromQuery] string deviceId, [FromB monograph.Content = null; monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var result = await Monographs.Collection.UpdateOneAsync( - CreateMonographFilter(userId, monograph), - Builders.Update - .Set(m => m.DatePublished, monograph.DatePublished) - .Set(m => m.CompressedContent, monograph.CompressedContent) - .Set(m => m.EncryptedContent, monograph.EncryptedContent) - .Set(m => m.SelfDestruct, monograph.SelfDestruct) - .Set(m => m.Title, monograph.Title) - .Set(m => m.Password, monograph.Password) - ); + var result = await Monographs.UpdateMonographAsync(userId, monograph); if (!result.IsAcknowledged) return BadRequest(); - await MarkMonographForSyncAsync(monograph.ItemId ?? monograph.Id, deviceId); + var jti = this.User.FindFirstValue("jti"); + await MarkMonographForSyncAsync(userId, jti, monograph.ItemId ?? monograph.Id, deviceId); return Ok(new { @@ -199,23 +138,15 @@ public async Task GetUserMonographsAsync() var userId = this.User.FindFirstValue("sub"); if (userId == null) return Unauthorized(); - var monographs = (await Monographs.Collection.FindAsync( - Builders.Filter.And( - Builders.Filter.Eq("UserId", userId), - Builders.Filter.Ne("Deleted", true) - ) - , new FindOptions - { - Projection = Builders.Projection.Include("_id").Include("ItemId"), - })).ToEnumerable(); - return Ok(monographs.Select((m) => m.ItemId ?? m.Id)); + var monographIds = await Monographs.GetUserMonographIdsAsync(userId); + return Ok(monographIds); } [HttpGet("{id}")] [AllowAnonymous] public async Task GetMonographAsync([FromRoute] string id) { - var monograph = await FindMonographAsync(id); + var monograph = await Monographs.FindByItemIdAsync(id); if (monograph == null || monograph.Deleted) { return NotFound(new @@ -235,24 +166,13 @@ public async Task GetMonographAsync([FromRoute] string id) [AllowAnonymous] public async Task TrackView([FromRoute] string id) { - var monograph = await FindMonographAsync(id); + var monograph = await Monographs.FindByItemIdAsync(id); if (monograph == null || monograph.Deleted) return Content(SVG_PIXEL, "image/svg+xml"); if (monograph.SelfDestruct) { - var userId = this.User.FindFirstValue("sub"); - await Monographs.Collection.ReplaceOneAsync( - CreateMonographFilter(userId, monograph), - new Monograph - { - ItemId = id, - Id = monograph.Id, - Deleted = true, - UserId = monograph.UserId - } - ); - - await MarkMonographForSyncAsync(id); + await Monographs.SelfDestructAsync(monograph, id); + await MarkMonographForSyncAsync(monograph.UserId, id); } return Content(SVG_PIXEL, "image/svg+xml"); @@ -261,7 +181,7 @@ await Monographs.Collection.ReplaceOneAsync( [HttpDelete("{id}")] public async Task DeleteAsync([FromQuery] string deviceId, [FromRoute] string id) { - var monograph = await FindMonographAsync(id); + var monograph = await Monographs.FindByItemIdAsync(id); if (monograph == null || monograph.Deleted) { return NotFound(new @@ -272,54 +192,26 @@ public async Task DeleteAsync([FromQuery] string deviceId, [FromR } var userId = this.User.FindFirstValue("sub"); - await Monographs.Collection.ReplaceOneAsync( - CreateMonographFilter(userId, monograph), - new Monograph - { - ItemId = id, - Id = monograph.Id, - Deleted = true, - UserId = monograph.UserId - } - ); + await Monographs.SoftDeleteAsync(userId, monograph, id); - await MarkMonographForSyncAsync(id, deviceId); + var jti = this.User.FindFirstValue("jti"); + await MarkMonographForSyncAsync(userId, jti, id, deviceId); return Ok(); } - private async Task MarkMonographForSyncAsync(string monographId, string deviceId) + private async Task MarkMonographForSyncAsync(string userId, string jti, string monographId, string deviceId) { if (deviceId == null) return; - var userId = this.User.FindFirstValue("sub"); - new SyncDeviceService(new SyncDevice(userId, deviceId)).AddIdsToOtherDevices([$"{monographId}:monograph"]); - await SendTriggerSyncEventAsync(); + syncDeviceServiceWrapper.MarkMonographForSyncAsync(userId, monographId, deviceId); + await messengerService.SendTriggerSyncEventAsync("Monographs updated", userId, jti, false); } - private async Task MarkMonographForSyncAsync(string monographId) + private async Task MarkMonographForSyncAsync(string userId, string monographId) { - var userId = this.User.FindFirstValue("sub"); - - new SyncDeviceService(new SyncDevice(userId, string.Empty)).AddIdsToAllDevices([$"{monographId}:monograph"]); - await SendTriggerSyncEventAsync(sendToAllDevices: true); - } - - private async Task SendTriggerSyncEventAsync(bool sendToAllDevices = false) - { - var userId = this.User.FindFirstValue("sub"); - var jti = this.User.FindFirstValue("jti"); - - await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage - { - OriginTokenId = sendToAllDevices ? null : jti, - UserId = userId, - Message = new Message - { - Type = "triggerSync", - Data = JsonSerializer.Serialize(new { reason = "Monographs updated." }) - } - }); + syncDeviceServiceWrapper.MarkMonographForSyncAsync(userId, monographId); + await messengerService.SendTriggerSyncEventAsync("Monographs updated", userId, null, true); } } } \ No newline at end of file diff --git a/Notesnook.API/Interfaces/IMessengerService.cs b/Notesnook.API/Interfaces/IMessengerService.cs new file mode 100644 index 0000000..1f6ca13 --- /dev/null +++ b/Notesnook.API/Interfaces/IMessengerService.cs @@ -0,0 +1,28 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Threading.Tasks; + +namespace Notesnook.API.Interfaces +{ + public interface IMessengerService + { + Task SendTriggerSyncEventAsync(string reason, string userId, string? originTokenId = null, bool sendToAllDevices = false); + } +} diff --git a/Notesnook.API/Interfaces/IMonographRepository.cs b/Notesnook.API/Interfaces/IMonographRepository.cs new file mode 100644 index 0000000..440c383 --- /dev/null +++ b/Notesnook.API/Interfaces/IMonographRepository.cs @@ -0,0 +1,40 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; +using MongoDB.Driver; +using Notesnook.API.Models; + +namespace Notesnook.API.Interfaces +{ + public interface IMonographRepository + { + Task FindByUserAndItemAsync(string userId, Monograph monograph); + Task FindByItemIdAsync(string itemId); + Task> GetUserMonographIdsAsync(string userId); + Task PublishOrUpdateAsync(string userId, Monograph monograph); + Task UpdateMonographAsync(string userId, Monograph monograph); + Task SoftDeleteAsync(string userId, Monograph monograph, string itemId); + Task SelfDestructAsync(Monograph monograph, string itemId); + Task> FindAsync(Expression> filterExpression); + void DeleteMany(Expression> filterExpression); + } +} diff --git a/Notesnook.API/Interfaces/ISyncDeviceServiceWrapper.cs b/Notesnook.API/Interfaces/ISyncDeviceServiceWrapper.cs new file mode 100644 index 0000000..d84c80d --- /dev/null +++ b/Notesnook.API/Interfaces/ISyncDeviceServiceWrapper.cs @@ -0,0 +1,26 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +namespace Notesnook.API.Interfaces +{ + public interface ISyncDeviceServiceWrapper + { + void MarkMonographForSyncAsync(string userId, string monographId, string? deviceId = null); + } +} diff --git a/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs b/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs index f8baab0..7b8d80c 100644 --- a/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs +++ b/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs @@ -19,7 +19,6 @@ You should have received a copy of the Affero GNU General Public License using Notesnook.API.Models; using Notesnook.API.Repositories; -using Streetwriters.Common.Models; using Streetwriters.Data.Repositories; namespace Notesnook.API.Interfaces @@ -39,6 +38,6 @@ public interface ISyncItemsRepositoryAccessor SyncItemsRepository Vaults { get; } SyncItemsRepository Tags { get; } Repository UsersSettings { get; } - Repository Monographs { get; } + IMonographRepository Monographs { get; } } } \ No newline at end of file diff --git a/Notesnook.API/Repositories/MonographRepository.cs b/Notesnook.API/Repositories/MonographRepository.cs new file mode 100644 index 0000000..ae37e6f --- /dev/null +++ b/Notesnook.API/Repositories/MonographRepository.cs @@ -0,0 +1,160 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; +using Notesnook.API.Interfaces; +using Notesnook.API.Models; +using Streetwriters.Data.Interfaces; +using Streetwriters.Data.Repositories; + +namespace Notesnook.API.Repositories +{ + public class MonographRepository : Repository, IMonographRepository + { + public MonographRepository(IDbContext dbContext, IMongoCollection collection) + : base(dbContext, collection) + { + } + + private static FilterDefinition CreateMonographFilter(string userId, Monograph monograph) + { + var userIdFilter = Builders.Filter.Eq("UserId", userId); + monograph.ItemId ??= monograph.Id; + return ObjectId.TryParse(monograph.ItemId, out ObjectId id) + ? Builders.Filter + .And(userIdFilter, + Builders.Filter.Or( + Builders.Filter.Eq("_id", id), + Builders.Filter.Eq("ItemId", monograph.ItemId) + ) + ) + : Builders.Filter + .And(userIdFilter, + Builders.Filter.Eq("ItemId", monograph.ItemId) + ); + } + + private static FilterDefinition CreateMonographFilter(string itemId) + { + return ObjectId.TryParse(itemId, out ObjectId id) + ? Builders.Filter.Or( + Builders.Filter.Eq("_id", id), + Builders.Filter.Eq("ItemId", itemId)) + : Builders.Filter.Eq("ItemId", itemId); + } + + public async Task FindByUserAndItemAsync(string userId, Monograph monograph) + { + var result = await Collection.FindAsync(CreateMonographFilter(userId, monograph), new FindOptions + { + Limit = 1 + }); + return await result.FirstOrDefaultAsync(); + } + + public async Task FindByItemIdAsync(string itemId) + { + var result = await Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions + { + Limit = 1 + }); + return await result.FirstOrDefaultAsync(); + } + + public async Task> GetUserMonographIdsAsync(string userId) + { + var monographs = (await Collection.FindAsync( + Builders.Filter.And( + Builders.Filter.Eq("UserId", userId), + Builders.Filter.Ne("Deleted", true) + ) + , new FindOptions + { + Projection = Builders.Projection.Include("_id").Include("ItemId"), + })).ToEnumerable(); + return monographs.Select((m) => m.ItemId ?? m.Id); + } + + public async Task PublishOrUpdateAsync(string userId, Monograph monograph) + { + return await Collection.ReplaceOneAsync( + CreateMonographFilter(userId, monograph), + monograph, + new ReplaceOptions { IsUpsert = true } + ); + } + + public async Task UpdateMonographAsync(string userId, Monograph monograph) + { + return await Collection.UpdateOneAsync( + CreateMonographFilter(userId, monograph), + Builders.Update + .Set(m => m.DatePublished, monograph.DatePublished) + .Set(m => m.CompressedContent, monograph.CompressedContent) + .Set(m => m.EncryptedContent, monograph.EncryptedContent) + .Set(m => m.SelfDestruct, monograph.SelfDestruct) + .Set(m => m.Title, monograph.Title) + .Set(m => m.Password, monograph.Password) + ); + } + + public async Task SoftDeleteAsync(string userId, Monograph monograph, string itemId) + { + return await Collection.ReplaceOneAsync( + CreateMonographFilter(userId, monograph), + new Monograph + { + ItemId = itemId, + Id = monograph.Id, + Deleted = true, + UserId = monograph.UserId + } + ); + } + + public async Task SelfDestructAsync(Monograph monograph, string itemId) + { + return await Collection.ReplaceOneAsync( + CreateMonographFilter(itemId), + new Monograph + { + ItemId = itemId, + Id = monograph.Id, + Deleted = true, + UserId = monograph.UserId + } + ); + } + + public new async Task> FindAsync(Expression> filterExpression) + { + return await base.FindAsync(filterExpression); + } + + public new void DeleteMany(Expression> filterExpression) + { + base.DeleteMany(filterExpression); + } + } +} diff --git a/Notesnook.API/Services/MessengerService.cs b/Notesnook.API/Services/MessengerService.cs new file mode 100644 index 0000000..54210a0 --- /dev/null +++ b/Notesnook.API/Services/MessengerService.cs @@ -0,0 +1,44 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Text.Json; +using System.Threading.Tasks; +using Notesnook.API.Interfaces; +using Streetwriters.Common; +using Streetwriters.Common.Messages; + +namespace Notesnook.API.Services +{ + public class MessengerService : IMessengerService + { + public async Task SendTriggerSyncEventAsync(string reason, string userId, string? originTokenId = null, bool sendToAllDevices = false) + { + await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage + { + OriginTokenId = sendToAllDevices ? null : originTokenId, + UserId = userId, + Message = new Message + { + Type = "triggerSync", + Data = JsonSerializer.Serialize(new { reason }) + } + }); + } + } +} diff --git a/Notesnook.API/Services/SyncDeviceServiceWrapper.cs b/Notesnook.API/Services/SyncDeviceServiceWrapper.cs new file mode 100644 index 0000000..4cfc85b --- /dev/null +++ b/Notesnook.API/Services/SyncDeviceServiceWrapper.cs @@ -0,0 +1,39 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using Notesnook.API.Interfaces; + +namespace Notesnook.API.Services +{ + public class SyncDeviceServiceWrapper : ISyncDeviceServiceWrapper + { + public void MarkMonographForSyncAsync(string userId, string monographId, string? deviceId = null) + { + if (deviceId == null) + { + new SyncDeviceService(new SyncDevice(userId, string.Empty)) + .AddIdsToAllDevices([$"{monographId}:monograph"]); + return; + } + + new SyncDeviceService(new SyncDevice(userId, deviceId)) + .AddIdsToOtherDevices([$"{monographId}:monograph"]); + } + } +} diff --git a/Notesnook.API/Startup.cs b/Notesnook.API/Startup.cs index a049119..2685738 100644 --- a/Notesnook.API/Startup.cs +++ b/Notesnook.API/Startup.cs @@ -166,9 +166,11 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddRepository("user_settings", "notesnook") - .AddRepository("monographs", "notesnook") .AddRepository("announcements", "notesnook"); + services.AddSingleton((provider) => MongoDbContext.GetMongoCollection(provider.GetService(), "notesnook", "monographs")); + services.AddScoped(); + services.AddMongoCollection(Collections.SettingsKey) .AddMongoCollection(Collections.AttachmentsKey) .AddMongoCollection(Collections.ContentKey) @@ -185,6 +187,8 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddControllers(); diff --git a/Notesnook.sln b/Notesnook.sln index 75c9443..b8c8dd3 100644 --- a/Notesnook.sln +++ b/Notesnook.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Messenger", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Identity", "Streetwriters.Identity\Streetwriters.Identity.csproj", "{6800DEE0-768C-4BEB-B78C-08829EC5A106}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notesnook.API.Tests", "Notesnook.API.Tests\Notesnook.API.Tests.csproj", "{37E7A5CC-67A2-48D4-8F86-7530C533FD7D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,5 +44,9 @@ Global {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Debug|Any CPU.Build.0 = Debug|Any CPU {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Release|Any CPU.ActiveCfg = Release|Any CPU {6800DEE0-768C-4BEB-B78C-08829EC5A106}.Release|Any CPU.Build.0 = Release|Any CPU + {37E7A5CC-67A2-48D4-8F86-7530C533FD7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37E7A5CC-67A2-48D4-8F86-7530C533FD7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37E7A5CC-67A2-48D4-8F86-7530C533FD7D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37E7A5CC-67A2-48D4-8F86-7530C533FD7D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal