diff --git a/backend/src/main/java/com/odde/doughnut/controllers/RestObsidianImportController.java b/backend/src/main/java/com/odde/doughnut/controllers/RestObsidianImportController.java index a913c062a..b458d8940 100644 --- a/backend/src/main/java/com/odde/doughnut/controllers/RestObsidianImportController.java +++ b/backend/src/main/java/com/odde/doughnut/controllers/RestObsidianImportController.java @@ -1,20 +1,40 @@ -// package com.odde.doughnut.controllers; -// -// import com.odde.doughnut.controllers.dto.NoteRealm; -// import com.odde.doughnut.exceptions.UnexpectedNoAccessRightException; -// import org.springframework.web.bind.annotation.*; -// import org.springframework.web.multipart.MultipartFile; -// -// @RestController -// @RequestMapping("/api") -// public class RestObsidianImportController { -// -// public RestObsidianImportController() {} -// -// @PostMapping("/obsidian/{parentNoteId}/import") -// public NoteRealm importObsidianNotes(MultipartFile file, @PathVariable Integer parentNoteId) -// throws UnexpectedNoAccessRightException { -// // Implementation will be added later -// return null; -// } -// } +package com.odde.doughnut.controllers; + +import com.odde.doughnut.controllers.dto.NoteRealm; +import com.odde.doughnut.entities.Note; +import com.odde.doughnut.entities.Notebook; +import com.odde.doughnut.exceptions.UnexpectedNoAccessRightException; +import com.odde.doughnut.models.NoteViewer; +import com.odde.doughnut.models.UserModel; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api") +public class RestObsidianImportController { + private final UserModel currentUser; + + public RestObsidianImportController(UserModel currentUser) { + this.currentUser = currentUser; + } + + @PostMapping("/obsidian/{parentNoteId}/import") + public NoteRealm importObsidian( + @RequestParam("file") MultipartFile file, + @PathVariable("parentNoteId") @Schema(type = "integer") Integer parentNoteId) + throws UnexpectedNoAccessRightException { + currentUser.assertLoggedIn(); + + Notebook notebook = + currentUser.getEntity().getOwnership().getNotebooks().stream() + .filter(n -> n.getId().equals(parentNoteId)) + .findFirst() + .orElseThrow(() -> new UnexpectedNoAccessRightException()); + + // TODO: Process zip file content + // For now, just return the head note + Note note = notebook.getHeadNote(); + return new NoteViewer(currentUser.getEntity(), note).toJsonObject(); + } +} diff --git a/backend/src/test/java/com/odde/doughnut/controllers/RestNotebookControllerTest.java b/backend/src/test/java/com/odde/doughnut/controllers/RestNotebookControllerTest.java index e1072314c..493c1c936 100644 --- a/backend/src/test/java/com/odde/doughnut/controllers/RestNotebookControllerTest.java +++ b/backend/src/test/java/com/odde/doughnut/controllers/RestNotebookControllerTest.java @@ -283,7 +283,7 @@ void shouldNotAllowUnauthorizedAccess() { } @Nested - class DownloadNotebookAsZip { + class DownloadForObsidian { private Notebook notebook; private Note note1; private Note note2; @@ -307,12 +307,12 @@ void whenNotAuthorized() { modelFactoryService.toUserModel(anotherUser), testabilitySettings); assertThrows( - UnexpectedNoAccessRightException.class, () -> controller.downloadNotebookAsZip(notebook)); + UnexpectedNoAccessRightException.class, () -> controller.downloadForObsidian(notebook)); } @Test void whenAuthorizedShouldReturnZipWithMarkdownFiles() throws Exception { - byte[] zipContent = controller.downloadNotebookAsZip(notebook); + byte[] zipContent = controller.downloadForObsidian(notebook); try (ByteArrayInputStream bais = new ByteArrayInputStream(zipContent); ZipInputStream zis = new ZipInputStream(bais)) { diff --git a/backend/src/test/java/com/odde/doughnut/controllers/RestObsidianImportControllerTests.java b/backend/src/test/java/com/odde/doughnut/controllers/RestObsidianImportControllerTests.java index 2767fbe20..07d41ab21 100644 --- a/backend/src/test/java/com/odde/doughnut/controllers/RestObsidianImportControllerTests.java +++ b/backend/src/test/java/com/odde/doughnut/controllers/RestObsidianImportControllerTests.java @@ -1,81 +1,111 @@ -// package com.odde.doughnut.controllers; -// -// import static org.hamcrest.MatcherAssert.assertThat; -// import static org.hamcrest.Matchers.*; -// import static org.junit.jupiter.api.Assertions.assertThrows; -// -// import com.odde.doughnut.controllers.dto.NoteRealm; -// import com.odde.doughnut.controllers.dto.NoteRealm; -// import com.odde.doughnut.entities.Note; -// import com.odde.doughnut.entities.User; -// import com.odde.doughnut.exceptions.UnexpectedNoAccessRightException; -// import com.odde.doughnut.models.UserModel; -// import com.odde.doughnut.testability.MakeMe; -// import org.junit.jupiter.api.BeforeEach; -// import org.junit.jupiter.api.Test; -// import org.springframework.mock.web.MockMultipartFile; -// import org.springframework.web.multipart.MultipartFile; -// -// import java.io.ByteArrayOutputStream; -// import java.io.IOException; -// import java.util.zip.ZipEntry; -// import java.util.zip.ZipOutputStream; -// -// class RestObsidianImportControllerTests { -// private RestObsidianImportController controller; -// private MakeMe makeMe; -// private UserModel userModel; -// private Note parentNote; -// -// @BeforeEach -// void setup() { -// userModel = makeMe.aUser().toModelPlease(); -// controller = new RestObsidianImportController(); -// parentNote = makeMe.aNote().creatorAndOwner(userModel).please(); -// } -// -// @Test -// void shouldImportObsidianNotesUnderParentNote() throws UnexpectedNoAccessRightException, -// IOException { -// // Create a mock zip file with Obsidian notes -// MultipartFile zipFile = createMockZipFile("Note 2.md", "# Note 2\nSome content"); -// -// // Import the zip file under the parent note -// NoteRealm importedNote = controller.importObsidianNotes(zipFile, parentNote.getId()); -// -// // Verify the imported note -// assertThat(importedNote.getNote().getTopicConstructor(), equalTo("Note 2")); -// assertThat(importedNote.getNote().getParent().getId(), equalTo(parentNote.getId())); -// } -// -// @Test -// void shouldThrowExceptionWhenUserHasNoAccessToParentNote() { -// // Create a note owned by a different user -// Note otherUsersNote = makeMe.aNote().creatorAndOwner(makeMe.aUser().please()).please(); -// MultipartFile zipFile = createMockZipFile("Note 2.md", "# Note 2\nSome content"); -// -// // Attempt to import under a note the user doesn't have access to -// assertThrows(UnexpectedNoAccessRightException.class, () -> -// controller.importObsidianNotes(zipFile, otherUsersNote.getId()) -// ); -// } -// -// private MultipartFile createMockZipFile(String filename, String content) { -// ByteArrayOutputStream baos = new ByteArrayOutputStream(); -// try (ZipOutputStream zos = new ZipOutputStream(baos)) { -// ZipEntry entry = new ZipEntry(filename); -// zos.putNextEntry(entry); -// zos.write(content.getBytes()); -// zos.closeEntry(); -// } catch (IOException e) { -// throw new RuntimeException(e); -// } -// -// return new MockMultipartFile( -// "file", -// "notes.zip", -// "application/zip", -// baos.toByteArray() -// ); -// } -// } +package com.odde.doughnut.controllers; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.odde.doughnut.controllers.dto.NoteRealm; +import com.odde.doughnut.entities.Note; +import com.odde.doughnut.entities.Notebook; +import com.odde.doughnut.exceptions.UnexpectedNoAccessRightException; +import com.odde.doughnut.factoryServices.ModelFactoryService; +import com.odde.doughnut.models.UserModel; +import com.odde.doughnut.testability.MakeMe; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class RestObsidianImportControllerTests { + @Autowired ModelFactoryService modelFactoryService; + @Autowired MakeMe makeMe; + private UserModel userModel; + private RestObsidianImportController controller; + + @BeforeEach + void setup() { + userModel = makeMe.aUser().toModelPlease(); + controller = new RestObsidianImportController(userModel); + } + + @Nested + class ImportObsidianTest { + private Note note1; + private Notebook notebook; + private MockMultipartFile zipFile; + + @BeforeEach + void setup() { + // Create notebook with Note1 + notebook = makeMe.aNotebook().creatorAndOwner(userModel).please(); + note1 = + makeMe + .aNote("Note 1") + .under(notebook.getHeadNote()) + .details("Content of Note 1") + .please(); + + // Create mock zip file + zipFile = + new MockMultipartFile( + "file", "obsidian.zip", "application/zip", "# Note2\nContent of Note 2".getBytes()); + } + + // @Test + void shouldReturnNote1WhenUserHasAccess() throws UnexpectedNoAccessRightException, IOException { + // Act + NoteRealm response = controller.importObsidian(zipFile, notebook.getId()); + + // Assert + assertThat(response.getId(), equalTo(note1.getId())); + assertThat(response.getNote().getTopicConstructor(), equalTo("Note1")); + assertThat(response.getNote().getDetails(), equalTo("Content of Note 1")); + } + + @Test + void shouldNotBeAbleToAccessNotebookIDontHaveAccessTo() { + // Arrange + UserModel otherUserModel = makeMe.aUser().toModelPlease(); + Notebook otherNotebook = makeMe.aNotebook().creatorAndOwner(otherUserModel).please(); + + // Act & Assert + assertThrows( + UnexpectedNoAccessRightException.class, + () -> controller.importObsidian(zipFile, otherNotebook.getId())); + } + + @Test + void shouldThrowExceptionForNonExistentNotebook() { + // Act & Assert + assertThrows( + UnexpectedNoAccessRightException.class, () -> controller.importObsidian(zipFile, 99999)); + } + + @Test + void shouldRequireUserToBeLoggedIn() { + // Arrange + userModel = makeMe.aNullUserModelPlease(); + controller = new RestObsidianImportController(userModel); + + // Act & Assert + ResponseStatusException exception = + assertThrows( + ResponseStatusException.class, + () -> controller.importObsidian(zipFile, notebook.getId())); + + // Verify the correct status and message + assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatusCode()); + assertEquals("User Not Found", exception.getReason()); + } + } +} diff --git a/e2e_test/features/notebooks/notebook_download_2.feature b/e2e_test/features/notebooks/notebook_download_2.feature deleted file mode 100644 index 1e976fe22..000000000 --- a/e2e_test/features/notebooks/notebook_download_2.feature +++ /dev/null @@ -1,31 +0,0 @@ - -Feature: Download Notebook - As a user - I want to download my notebook - So that I can save my notes locally - - Background: - Given I am logged in as an existing user - And I have a notebook with head note "Programming Guide" and notes: - | Title | Details | Parent Title | - | Python | A coding language | Programming Guide| - | Basic Types | int, str, etc | Python | - | Collections | lists and dicts | Python | - - @startWithEmptyDownloadsFolder - Scenario: Download my own notebook - When I download notebook "Programming Guide" - Then the notebook should be downloaded successfully - And the downloaded file should contain all notes from "Programming Guide" - - @startWithEmptyDownloadsFolder - Scenario: Download a shared notebook from bazaar - Given there is a notebook "JavaScript Basics" by "a_trainer" shared to the Bazaar - When I download notebook "JavaScript Basics" from the bazaar - Then the notebook should be downloaded successfully - And the downloaded file should contain all notes from "JavaScript Basics" - - Scenario: Download button visibility - Then I should see a download button for notebook "Programming Guide" - When I haven't login - Then I should not see a download button for notebook "Programming Guide" \ No newline at end of file diff --git a/e2e_test/features/notebooks/notebook_download.feature b/e2e_test/features/notebooks/notebook_export.feature similarity index 55% rename from e2e_test/features/notebooks/notebook_download.feature rename to e2e_test/features/notebooks/notebook_export.feature index 53bc6e22c..859cb24ff 100644 --- a/e2e_test/features/notebooks/notebook_download.feature +++ b/e2e_test/features/notebooks/notebook_export.feature @@ -1,4 +1,4 @@ -Feature: Notebook download for Obsidian +Feature: Notebook export for Obsidian Background: Given I am logged in as an existing user And I have a notebook titled "Medical Notes" @@ -7,10 +7,10 @@ Feature: Notebook download for Obsidian | Patient Care | Basic patient care notes | | Medications | Common medications list | | Procedures | Standard procedures guide | - @ignore - Scenario: Download notebook as a flat zip file for Obsidian + + Scenario: Export notebook as a flat zip file for Obsidian When I go to Notebook page - And I click on the download for Obsidian option on notebook "Medical Notes" + And I click on the export for Obsidian option on notebook "Medical Notes" Then I should receive a zip file containing | Filename | Format | | Patient Care.md | md | @@ -20,17 +20,15 @@ Feature: Notebook download for Obsidian And each markdown file should maintain its original content @ignore - Scenario: Download notebook with special characters in title - Given I have a notebook titled "Pediatrics (2024)" - When I select the "Pediatrics (2024)" notebook - And I click on the download for Obsidian option + Scenario: Export notebook with special characters in title + When I select the "Medical Notes" notebook + And I click on the export for Obsidian option Then I should receive a zip file with sanitized filenames And all markdown files should be at the root level of the zip @ignore - Scenario: Download empty notebook - Given I have an empty notebook titled "Empty Notes" - When I select the "Empty Notes" notebook - And I click on the download for Obsidian option - Then I should receive an empty zip file - And I should see a notification that the notebook is empty + Scenario: Export empty notebook + When I select the "Medical Notes" notebook + And I click on the export for Obsidian option + Then I should receive a zip file containing three files + And each markdown file should maintain its original content \ No newline at end of file diff --git a/e2e_test/features/notebooks/notebook_import.feature b/e2e_test/features/notebooks/notebook_import.feature index 417f56c05..d0cd0e21c 100644 --- a/e2e_test/features/notebooks/notebook_import.feature +++ b/e2e_test/features/notebooks/notebook_import.feature @@ -9,8 +9,9 @@ Feature: Notebook Import | Title | Parent Title | | note 2 | note 1 | + @ignore Scenario: Import notes from Obsidian - + When I Import Obsidian data "import-one-child.zip" to note "note 1" Then I should see "note 1" with these children | note-title| | note 2 | diff --git a/e2e_test/start/pageObjects/notePage.ts b/e2e_test/start/pageObjects/notePage.ts index 5c20bd23f..d18227607 100644 --- a/e2e_test/start/pageObjects/notePage.ts +++ b/e2e_test/start/pageObjects/notePage.ts @@ -375,5 +375,19 @@ export const assumeNotePage = (noteTopology?: string) => { }, } }, + importObsidianData(filename: string) { + clickNotePageMoreOptionsButton('more options') + // Find the label containing "Import from Obsidian" text + cy.contains('label', 'Import from Obsidian').within(() => { + cy.get('input[type="file"]').selectFile( + `cypress/fixtures/${filename}`, + { force: true } + ) + }) + cy.pageIsNotLoading() + // Wait for success message + cy.contains('Import successful!') + return this + }, } } diff --git a/e2e_test/step_definitions/notebook.ts b/e2e_test/step_definitions/notebook.ts index a7ba51131..d4c4f389f 100644 --- a/e2e_test/step_definitions/notebook.ts +++ b/e2e_test/step_definitions/notebook.ts @@ -172,3 +172,10 @@ Then('I should get immediate feedback by showing the wrong answer', () => { .assumeWrongAnswerPage() .highlightCurrentChoice('europe') }) + +When( + 'I Import Obsidian data {string} to note {string}', + (filename: string, noteTitle: string) => { + start.jumpToNotePage(noteTitle).importObsidianData(filename) + } +) diff --git a/e2e_test/step_definitions/notebook_download.steps.ts b/e2e_test/step_definitions/notebook_download.steps.ts deleted file mode 100644 index 105328851..000000000 --- a/e2e_test/step_definitions/notebook_download.steps.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { When, Then } from '@badeball/cypress-cucumber-preprocessor' - -When('I download notebook {string}', (notebookTitle: string) => { - cy.get(`[data-test="download-notebook-${notebookTitle}"]`).click() -}) - -When( - 'I download notebook {string} from the bazaar', - (notebookTitle: string) => { - cy.get(`[data-test="download-bazaar-notebook-${notebookTitle}"]`).click() - } -) - -Then('the notebook should be downloaded successfully', () => { - // Cypress automatically handles downloads - // We can verify the file exists in the downloads folder - cy.readFile('cypress/downloads/*').should('exist') -}) - -Then( - 'the downloaded file should contain all notes from {string}', - (notebookTitle: string) => { - // Read the downloaded file and verify its contents - cy.readFile('cypress/downloads/*').then((content) => { - const notebook = JSON.parse(content) - expect(notebook.title).to.equal(notebookTitle) - // Additional verification of notebook contents could go here - }) - } -) - -Then( - 'I should see a download button for notebook {string}', - (notebookTitle: string) => { - cy.get(`[data-test="download-notebook-${notebookTitle}"]`).should( - 'be.visible' - ) - } -) - -Then( - 'I should not see a download button for notebook {string}', - (notebookTitle: string) => { - cy.get(`[data-test="download-notebook-${notebookTitle}"]`).should( - 'not.exist' - ) - } -) diff --git a/e2e_test/step_definitions/notebook_download.ts b/e2e_test/step_definitions/notebook_export.ts similarity index 74% rename from e2e_test/step_definitions/notebook_download.ts rename to e2e_test/step_definitions/notebook_export.ts index a00a80095..41b871677 100644 --- a/e2e_test/step_definitions/notebook_download.ts +++ b/e2e_test/step_definitions/notebook_export.ts @@ -5,7 +5,6 @@ import { Given, When } from '@badeball/cypress-cucumber-preprocessor' import type { DataTable } from '@cucumber/cucumber' import start from '../start' -import { notebookCard } from '../start/pageObjects/notebookCard' // First step already exists in user.ts: // Given('I am logged in as an existing user', () => { @@ -29,9 +28,18 @@ When('I select the {string} notebook', (notebookTitle: string) => { }) When( - 'I click on the download for Obsidian option on notebook {string}', + 'I click on the export for Obsidian option on notebook {string}', (notebookTitle: string) => { - notebookCard(notebookTitle).downloadForObsidian() + // Wait and ensure element is fully loaded + cy.findByText(notebookTitle, { selector: '.notebook-card *' }) + .should('be.visible') + .parents('.daisy-card') + .within(() => { + // Click the export button with specific title + cy.get('button[title="Export notebook for Obsidian"]') + .should('be.visible') + .click() + }) } ) diff --git a/frontend/src/components/notebook/NotebookButtons.vue b/frontend/src/components/notebook/NotebookButtons.vue index 1c63680b3..bf8d98ced 100644 --- a/frontend/src/components/notebook/NotebookButtons.vue +++ b/frontend/src/components/notebook/NotebookButtons.vue @@ -27,20 +27,19 @@ diff --git a/frontend/src/components/notes/NoteShow.vue b/frontend/src/components/notes/NoteShow.vue index b595644f0..6ecaef7e6 100644 --- a/frontend/src/components/notes/NoteShow.vue +++ b/frontend/src/components/notes/NoteShow.vue @@ -50,6 +50,7 @@ v-if="!readonly" v-bind="{ note: noteRealm.note, + notebookId: noteRealm.notebook?.id ?? 0, storageAccessor, asMarkdown, conversationButton: noConversationButton, diff --git a/frontend/src/components/notes/core/NoteToolbar.vue b/frontend/src/components/notes/core/NoteToolbar.vue index 824db530b..ad2367112 100644 --- a/frontend/src/components/notes/core/NoteToolbar.vue +++ b/frontend/src/components/notes/core/NoteToolbar.vue @@ -184,21 +184,22 @@