Add public note sharing feature (#22)

* Initial plan

* Add note sharing feature: share/unshare endpoints, public note view, share token

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* Remove redundant nullable=true annotation from NoteEntity shareToken column

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* Fix checkstyle line-length violations in test files

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* Fix frontend build and test failures: add shared/shareToken fields to NoteAdd payloads and test

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* chore: reorganize scripts and migration name

* docs: update readme with new feature

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
This commit is contained in:
Copilot
2026-02-28 13:08:23 -03:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> RMCampos rmcampos
parent 91e48f2cbb
commit c3115bc002
38 changed files with 647 additions and 449 deletions
+1 -1
View File
@@ -40,10 +40,10 @@ The project was born from a month-long technical challenge and has since grown t
- **File Attachments**: URL attachments for tasks and notes
- **Tagging System**: `#tag` support for better organization
- **Mobile App**: Native mobile applications for iOS and Android with PWA plugin
- **Collaboration**: Share tasks and notes with other users
### Upcoming Features
- **Advanced Filters**: Enhanced search with date ranges, priority levels, and status filters
- **Collaboration**: Share tasks and notes with other users
- **Notifications**: Email and push notifications for due dates and reminders
## 🚀 Tech Stack
-37
View File
@@ -1,37 +0,0 @@
#!/bin/bash
# Client - Front-end
npm ci
if [ $? -eq 1 ]; then
echo "Issues when installing dependencies. Please review.."
exit 1
fi
if [ -z "$CHECK" ]; then
npm start
else
echo "Running checks..."
echo "1/3 - Lint started..."
npm run lint:fix
if [ $? -eq 1 ]; then
echo "Issues when running lint. Please review.."
exit 1
fi
echo "2/3 - Build started..."
npm run build
if [ $? -eq 1 ]; then
echo "Issues when running build. Please review.."
exit 1
fi
echo "3/3 - Tests started..."
npm run test:no-watch
if [ $? -eq 1 ]; then
echo "Issues when running test. Please review.."
exit 1
fi
echo "You're good to go! Good job!"
exit 0
fi
+9
View File
@@ -16,6 +16,7 @@ import Register from './views/Register';
import EmailConfirmation from './views/EmailConfirmation';
import ResetPassword from './views/ResetPassword';
import CompleteResetPassword from './views/CompleteResetPassword';
import SharedNote from './views/SharedNote';
import './styles/custom.scss';
/**
@@ -65,6 +66,10 @@ function App(): React.ReactNode {
path: '/finish-reset-password',
element: <CompleteResetPassword />
},
{
path: '/public/notes/:token',
element: <SharedNote />
},
{
path: '*',
element: <Navigate to="/" replace />
@@ -86,6 +91,10 @@ function App(): React.ReactNode {
}
]
},
{
path: '/public/notes/:token',
element: <SharedNote />
},
{
path: '*',
element: <NotFound />
+3 -1
View File
@@ -143,7 +143,9 @@ describe('NoteAdd Component', () => {
description: 'Note content',
url: '',
tag: '',
lastUpdate: ''
lastUpdate: '',
shared: false,
shareToken: null
}
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
});
+5
View File
@@ -81,6 +81,11 @@ const api = {
return handleResponse(response);
},
getJSONNoAuth: async (url: string) => {
const response = await fetch(url, getRequestInit('GET', {}, false));
return handleResponse(response);
},
postJSON: async (url: string, payload: object) => {
const response = await fetch(url, getRequestInit('POST', payload, isAddAuth(url)));
return handleResponse(response);
+2
View File
@@ -26,6 +26,8 @@ const ApiConfig = {
notesUrl: `${server}/rest/notes`,
publicNotesUrl: `${server}/public/notes`,
userUrl: `${server}/rest/users`
};
+3
View File
@@ -114,6 +114,9 @@ const enTranslations = {
note_form_submit: 'Save note',
note_table_btn_edit: 'Edit',
note_table_btn_delete: 'Delete',
note_action_share: 'Share',
note_action_unshare: 'Unshare',
note_action_copy_link: 'Copy link',
about_page_title_one: 'About the',
about_page_title_two: 'TaskNote App',
+3
View File
@@ -114,6 +114,9 @@ const ptBrTranslations = {
note_form_submit: 'Salvar nota',
note_table_btn_edit: 'Alterar',
note_table_btn_delete: 'Excluir',
note_action_share: 'Compartilhar',
note_action_unshare: 'Parar de compartilhar',
note_action_copy_link: 'Copiar link',
about_page_title_one: 'Sobre o',
about_page_title_two: 'App TaskNote',
+3
View File
@@ -114,6 +114,9 @@ const ruTranslations = {
note_form_submit: 'Сохранить заметку',
note_table_btn_edit: 'Редактировать',
note_table_btn_delete: 'Удалить',
note_action_share: 'Поделиться',
note_action_unshare: 'Закрыть доступ',
note_action_copy_link: 'Копировать ссылку',
about_page_title_one: 'около',
about_page_title_two: 'TaskNote App',
+3
View File
@@ -114,6 +114,9 @@ const esTranslations = {
note_form_submit: 'Guardar nota',
note_table_btn_edit: 'Editar',
note_table_btn_delete: 'Eliminar',
note_action_share: 'Compartir',
note_action_unshare: 'Dejar de compartir',
note_action_copy_link: 'Copiar enlace',
about_page_title_one: 'Acerca de',
about_page_title_two: 'TaskNote App',
+2
View File
@@ -5,6 +5,8 @@ type NoteResponse = {
url: string | null;
tag: string;
lastUpdate: string;
shared: boolean;
shareToken: string | null;
};
export type { NoteResponse };
+44
View File
@@ -104,6 +104,34 @@ function Home(): React.ReactNode {
}
};
/**
* Share or unshare a note.
*
* @param {NoteResponse} note The note to share or unshare.
*/
const toggleShareNote = async (note: NoteResponse): Promise<void> => {
try {
const action = note.shared ? 'unshare' : 'share';
await api.putJSON(`${ApiConfig.notesUrl}/${note.id}/${action}`, {});
loadAllNotes();
}
catch (e) {
handleError(e);
}
};
/**
* Copy share link to clipboard.
*
* @param {NoteResponse} note The shared note.
*/
const copyShareLink = (note: NoteResponse): void => {
const link = `${window.location.origin}/public/notes/${note.shareToken}`;
navigator.clipboard.writeText(link).catch(() => {
setErrorMessage('Failed to copy link to clipboard.');
});
};
/**
* Apply filters to a given set of tasks and notes, updating displayed state.
*
@@ -528,6 +556,22 @@ function Home(): React.ReactNode {
{t('task_table_action_clone')}
</Dropdown.Item>
</NavLink>
<Dropdown.Item
as="button"
onClick={() => toggleShareNote(note)}
data-testid={`note-dropdown-share-item-${note.id}`}
>
{note.shared ? t('note_action_unshare') : t('note_action_share')}
</Dropdown.Item>
{note.shared && note.shareToken && (
<Dropdown.Item
as="button"
onClick={() => copyShareLink(note)}
data-testid={`note-dropdown-copy-link-${note.id}`}
>
{t('note_action_copy_link')}
</Dropdown.Item>
)}
<Dropdown.Item
as="button"
onClick={() => deleteNote(note.id)}
+6 -2
View File
@@ -140,7 +140,9 @@ function NoteAdd(): React.ReactNode {
description: noteContent,
url: noteUrl,
tag: noteTag,
lastUpdate: ''
lastUpdate: '',
shared: false,
shareToken: null
};
const added: boolean = await addNote(payload);
@@ -157,7 +159,9 @@ function NoteAdd(): React.ReactNode {
description: noteContent,
url: noteUrl,
tag: noteTag,
lastUpdate: ''
lastUpdate: '',
shared: false,
shareToken: null
};
const edited: boolean = await submitEditNote(payload);
+108
View File
@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Container, Row } from 'react-bootstrap';
import { useParams } from 'react-router';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { NoteResponse } from '../../types/NoteResponse';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
/**
* SharedNote component for displaying a publicly shared note.
* Accessible without authentication.
*
* @returns {React.ReactNode} The rendered SharedNote component.
*/
function SharedNote(): React.ReactNode {
const { token } = useParams<{ token: string }>();
const [note, setNote] = useState<NoteResponse | null>(null);
const [errorMessage, setErrorMessage] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
if (!token) {
setErrorMessage('Invalid share link.');
setLoading(false);
return;
}
api
.getJSONNoAuth(`${ApiConfig.publicNotesUrl}/${token}`)
.then((data: NoteResponse) => {
setNote(data);
})
.catch(() => {
setErrorMessage('Note not found or no longer shared.');
})
.finally(() => {
setLoading(false);
});
}, [token]);
if (loading) {
return (
<Container fluid className="mt-5 text-center">
<p>Loading...</p>
</Container>
);
}
if (errorMessage || !note) {
return (
<Container fluid className="mt-5">
<Row className="justify-content-center">
<Col xs={12} md={8}>
<Card>
<Card.Body>
<Card.Title>Note not found</Card.Title>
<p className="text-muted">{errorMessage || 'This note is not available.'}</p>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
);
}
return (
<Container fluid className="mt-3">
<Row className="justify-content-center">
<Col xs={12} md={10} lg={8}>
<Card>
<Card.Header className="d-flex justify-content-between align-items-center">
<small className="text-muted">TaskNote · Shared Note (Read only)</small>
{note.tag && (
<small className="text-muted">
#
{note.tag}
</small>
)}
</Card.Header>
<Card.Body>
<Card.Title>{note.title}</Card.Title>
{note.url && (
<p>
<a href={note.url} target="_blank" rel="noopener noreferrer">
{note.url}
</a>
</p>
)}
<Markdown remarkPlugins={[remarkGfm]}>{note.description}</Markdown>
</Card.Body>
{note.lastUpdate && (
<Card.Footer className="text-muted">
<small>
Last updated:
{' '}
{note.lastUpdate}
</small>
</Card.Footer>
)}
</Card>
</Col>
</Row>
</Container>
);
}
export default SharedNote;
-34
View File
@@ -1,34 +0,0 @@
#!/bin/bash
# Server - Back-end
if [ -z "$CHECK" ]; then
./mvnw -ntp \
spring-boot:run \
-Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" \
-Dmaven.plugin.validation=VERBOSE
else
echo "Running checks..."
echo "1/3 - Check Style started..."
./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
if [ $? -eq 1 ]; then
echo "Issues when running Check Style. Please review.."
exit 1
fi
echo "2/3 - Build started..."
./mvnw --no-transfer-progress clean compile -DskipTests
if [ $? -eq 1 ]; then
echo "Issues when running build. Please review.."
exit 1
fi
echo "3/3 - Tests started..."
./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
if [ $? -eq 1 ]; then
echo "Issues when running test. Please review.."
exit 1
fi
echo "You're good to go! Good job!"
exit 0
fi
@@ -51,6 +51,8 @@ public class SecurityConfig {
.permitAll()
.requestMatchers("/auth/**")
.permitAll()
.requestMatchers("/public/**")
.permitAll()
.requestMatchers("/rest/**")
.authenticated()
.anyRequest()
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -91,4 +92,28 @@ public class NoteController {
noteService.deleteNote(id);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
/**
* Share a note publicly.
*
* @param id Note identification.
* @return NoteResponse containing the share token.
* @throws NoteNotFoundException when note not found.
*/
@PutMapping("/{id}/share")
public ResponseEntity<NoteResponse> shareNote(@PathVariable Long id) {
return ResponseEntity.ok(noteService.shareNote(id));
}
/**
* Unshare a note, revoking public access.
*
* @param id Note identification.
* @return NoteResponse with the updated note.
* @throws NoteNotFoundException when note not found.
*/
@PutMapping("/{id}/unshare")
public ResponseEntity<NoteResponse> unshareNote(@PathVariable Long id) {
return ResponseEntity.ok(noteService.unshareNote(id));
}
}
@@ -0,0 +1,34 @@
package br.com.tasknoteapp.server.controller;
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
import br.com.tasknoteapp.server.response.NoteResponse;
import br.com.tasknoteapp.server.service.NoteService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** This class provides public (unauthenticated) resources for shared notes. */
@RestController
@RequestMapping("/public/notes")
public class PublicNoteController {
private final NoteService noteService;
public PublicNoteController(NoteService noteService) {
this.noteService = noteService;
}
/**
* Get a publicly shared note by its share token.
*
* @param token The unique share token for the note.
* @return NoteResponse containing the shared note data.
* @throws NoteNotFoundException when note is not found or not shared.
*/
@GetMapping("/{token}")
public ResponseEntity<NoteResponse> getSharedNote(@PathVariable String token) {
return ResponseEntity.ok(noteService.getSharedNote(token));
}
}
@@ -39,6 +39,12 @@ public class NoteEntity {
@Column(name = "last_update")
private LocalDateTime lastUpdate;
@Column(name = "shared", nullable = false)
private boolean shared = false;
@Column(name = "share_token", length = 36)
private String shareToken;
public Long getId() {
return id;
}
@@ -95,6 +101,22 @@ public class NoteEntity {
this.lastUpdate = lastUpdate;
}
public boolean isShared() {
return shared;
}
public void setShared(boolean shared) {
this.shared = shared;
}
public String getShareToken() {
return shareToken;
}
public void setShareToken(String shareToken) {
this.shareToken = shareToken;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
import br.com.tasknoteapp.server.entity.NoteEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -10,6 +11,8 @@ public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
List<NoteEntity> findAllByUser_id(Long userId);
Optional<NoteEntity> findByShareToken(String shareToken);
@Query(
"select n from NoteEntity n where (upper(n.title) like %?1% or upper(n.description) like"
+ " %?1%) and n.user.id = ?2")
@@ -7,7 +7,8 @@ import java.util.Objects;
/** This record represents a task and its urls object to be returned. */
public record NoteResponse(
Long id, String title, String description, String url, String lastUpdate, String tag) {
Long id, String title, String description, String url, String lastUpdate, String tag,
boolean shared, String shareToken) {
/**
* Creates a NoteResponse given a NoteEntity and its Urls.
@@ -26,6 +27,8 @@ public record NoteResponse(
entity.getDescription(),
url,
timeAgoFmt,
entity.getTag());
entity.getTag(),
entity.isShared(),
entity.getShareToken());
}
}
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -215,6 +216,73 @@ public class NoteService {
return notes.stream().map(NoteResponse::fromEntity).toList();
}
/**
* Share a note publicly, generating a unique share token.
*
* @param noteId The note id from the database.
* @return {@link NoteResponse} containing the updated note with share token.
*/
public NoteResponse shareNote(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Sharing note " + noteId + " for user " + user.getId());
Optional<NoteEntity> noteOpt = noteRepository.findById(noteId);
if (noteOpt.isEmpty()) {
throw new NoteNotFoundException();
}
NoteEntity noteEntity = noteOpt.get();
if (!noteEntity.isShared()) {
noteEntity.setShared(true);
noteEntity.setShareToken(UUID.randomUUID().toString());
noteRepository.save(noteEntity);
logger.info("Note " + noteId + " shared with token " + noteEntity.getShareToken());
}
return NoteResponse.fromEntity(noteEntity);
}
/**
* Unshare a note, revoking public access.
*
* @param noteId The note id from the database.
* @return {@link NoteResponse} containing the updated note.
*/
public NoteResponse unshareNote(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Unsharing note " + noteId + " for user " + user.getId());
Optional<NoteEntity> noteOpt = noteRepository.findById(noteId);
if (noteOpt.isEmpty()) {
throw new NoteNotFoundException();
}
NoteEntity noteEntity = noteOpt.get();
noteEntity.setShared(false);
noteEntity.setShareToken(null);
noteRepository.save(noteEntity);
logger.info("Note " + noteId + " unshared.");
return NoteResponse.fromEntity(noteEntity);
}
/**
* Get a publicly shared note by its share token (no authentication required).
*
* @param shareToken The unique share token for the note.
* @return {@link NoteResponse} containing the shared note.
*/
public NoteResponse getSharedNote(String shareToken) {
logger.info("Fetching shared note with token " + shareToken);
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
throw new NoteNotFoundException();
}
return NoteResponse.fromEntity(noteOpt.get());
}
private UserEntity getCurrentUser() {
Optional<String> currentUserEmail = authUtil.getCurrentUserEmail();
String email = currentUserEmail.orElseThrow();
@@ -0,0 +1,3 @@
ALTER TABLE tasknote.notes
ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN share_token VARCHAR(36) NULL;
@@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -44,7 +45,7 @@ class NoteControllerTest {
void getAllNotes_notesFound_shouldSucceed() throws Exception {
NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com");
NoteResponse note =
new NoteResponse(111L, "title", "description", "https://test.com", null, "tag");
new NoteResponse(111L, "title", "description", "https://test.com", null, "tag", false, null);
when(noteService.getAllNotes()).thenReturn(List.of(note));
@@ -102,7 +103,14 @@ class NoteControllerTest {
NoteResponse response =
new NoteResponse(
noteId, patchRequest.title(), patchRequest.description(), null, null, "tag");
noteId,
patchRequest.title(),
patchRequest.description(),
null,
null,
"tag",
false,
null);
when(noteService.patchNote(noteId, patchRequest)).thenReturn(response);
@@ -315,4 +323,75 @@ class NoteControllerTest {
.andExpect(status().isNotFound())
.andReturn();
}
@Test
@DisplayName("Share note happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void shareNote_happyPath_shouldSucceed() throws Exception {
final Long noteId = 1L;
final String token = "test-token-uuid";
NoteResponse response =
new NoteResponse(noteId, "title", "description", null, null, "tag", true, token);
when(noteService.shareNote(noteId)).thenReturn(response);
mockMvc
.perform(
put("/rest/notes/{id}/share", noteId)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.shared").value(true))
.andExpect(jsonPath("$.shareToken").value(token))
.andReturn();
}
@Test
@DisplayName("Share note with 401 unauthorized should fail")
void shareNote_unauthorized_shouldFail() throws Exception {
mockMvc
.perform(
put("/rest/notes/{id}/share", 1L)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andReturn();
}
@Test
@DisplayName("Unshare note happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void unshareNote_happyPath_shouldSucceed() throws Exception {
final Long noteId = 1L;
NoteResponse response =
new NoteResponse(noteId, "title", "description", null, null, "tag", false, null);
when(noteService.unshareNote(noteId)).thenReturn(response);
mockMvc
.perform(
put("/rest/notes/{id}/unshare", noteId)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.shared").value(false))
.andExpect(jsonPath("$.shareToken", Matchers.nullValue()))
.andReturn();
}
@Test
@DisplayName("Unshare note with 401 unauthorized should fail")
void unshareNote_unauthorized_shouldFail() throws Exception {
mockMvc
.perform(
put("/rest/notes/{id}/unshare", 1L)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andReturn();
}
}
@@ -0,0 +1,68 @@
package br.com.tasknoteapp.server.controller;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
import br.com.tasknoteapp.server.response.NoteResponse;
import br.com.tasknoteapp.server.service.NoteService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class PublicNoteControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private NoteService noteService;
@Test
@DisplayName("Get shared note by token happy path should succeed")
void getSharedNote_happyPath_shouldSucceed() throws Exception {
final String token = "test-share-token";
NoteResponse response =
new NoteResponse(1L, "title", "description", null, null, "tag", true, token);
when(noteService.getSharedNote(token)).thenReturn(response);
mockMvc
.perform(
get("/public/notes/{token}", token)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.title").value("title"))
.andExpect(jsonPath("$.shared").value(true))
.andExpect(jsonPath("$.shareToken").value(token))
.andReturn();
}
@Test
@DisplayName("Get shared note by token not found should fail with 404")
void getSharedNote_notFound_shouldFail() throws Exception {
final String token = "invalid-token";
when(noteService.getSharedNote(token)).thenThrow(new NoteNotFoundException());
mockMvc
.perform(
get("/public/notes/{token}", token)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andReturn();
}
}
@@ -162,4 +162,82 @@ class NoteServiceTest {
assertEquals("Test Note", notes.get(0).title());
verify(noteRepository, times(1)).findAllBySearchTerm(anyString(), eq(user.getId()));
}
@Test
void shareNote() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
when(noteRepository.findById(note.getId())).thenReturn(Optional.of(note));
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
NoteResponse response = noteService.shareNote(note.getId());
assertEquals("Test Note", response.title());
verify(noteRepository, times(1)).save(any(NoteEntity.class));
}
@Test
void shareNote_notFound() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
when(noteRepository.findById(note.getId())).thenReturn(Optional.empty());
Long noteId = note.getId();
assertThrows(NoteNotFoundException.class, () -> noteService.shareNote(noteId));
}
@Test
void unshareNote() {
note.setShared(true);
note.setShareToken("some-token");
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
when(noteRepository.findById(note.getId())).thenReturn(Optional.of(note));
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
NoteResponse response = noteService.unshareNote(note.getId());
assertEquals("Test Note", response.title());
verify(noteRepository, times(1)).save(any(NoteEntity.class));
}
@Test
void unshareNote_notFound() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
when(noteRepository.findById(note.getId())).thenReturn(Optional.empty());
Long noteId = note.getId();
assertThrows(NoteNotFoundException.class, () -> noteService.unshareNote(noteId));
}
@Test
void getSharedNote() {
final String token = "share-token-123";
note.setShared(true);
note.setShareToken(token);
when(noteRepository.findByShareToken(token)).thenReturn(Optional.of(note));
NoteResponse response = noteService.getSharedNote(token);
assertEquals("Test Note", response.title());
verify(noteRepository, times(1)).findByShareToken(token);
}
@Test
void getSharedNote_notFound() {
when(noteRepository.findByShareToken("bad-token")).thenReturn(Optional.empty());
assertThrows(NoteNotFoundException.class, () -> noteService.getSharedNote("bad-token"));
}
@Test
void getSharedNote_notShared() {
final String token = "share-token-456";
note.setShared(false);
note.setShareToken(token);
when(noteRepository.findByShareToken(token)).thenReturn(Optional.of(note));
assertThrows(NoteNotFoundException.class, () -> noteService.getSharedNote(token));
}
}
@@ -43,7 +43,8 @@ class UserSessionServiceTest {
TaskResponse task =
new TaskResponse(1L, "Task 1", false, true, null, null, null, null, List.of());
NoteResponse note = new NoteResponse(1L, "Note 1", "Description", null, null, null);
NoteResponse note =
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null);
when(authService.getCurrentUser()).thenReturn(Optional.of(user));
when(taskService.getAllTasks()).thenReturn(List.of(task));
+27 -22
View File
@@ -1,28 +1,33 @@
#!/bin/bash
# Server - Back-end
DOCKER_IMG="tasknote-api:nightly"
CURRENT_DIR=$(pwd)
docker image inspect $DOCKER_IMG --format="ignore me"
if [ $? -eq 1 ]; then
echo "Image not found locally! Building it..."
if [ ! -f "server/.env" ]; then
echo "No env file found for back-end. Creating one for you.."
bash tools/run-create-env.sh "back"
else
echo "Env file in place. Moving on.."
fi
echo "Getting env vars and making them visible"
cd server/
export $(cat .env | xargs)
echo "Done!"
cd ..
docker build --file server/Dockerfile.dev --tag tasknote-api:nightly .
else
echo "Image found! Let's keep going."
if [ ! -f "$CURRENT_DIR/pom.xml" ]; then
cd "$CURRENT_DIR/server"
fi
echo "Running checks..."
echo "1/3 - Check Style started..."
./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
if [ $? -eq 1 ]; then
echo "Issues when running Check Style. Please review.."
exit 1
fi
docker run -it --rm --name tasknote-api -e CHECK="TRUE" -v ./server:/app tasknote-api:nightly
echo "2/3 - Build started..."
./mvnw --no-transfer-progress clean compile -DskipTests
if [ $? -eq 1 ]; then
echo "Issues when running build. Please review.."
exit 1
fi
echo "3/3 - Tests started..."
./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
if [ $? -eq 1 ]; then
echo "Issues when running test. Please review.."
exit 1
fi
echo "You're good to go! Good job!"
exit 0
Regular → Executable
+37 -1
View File
@@ -1,3 +1,39 @@
#!/bin/bash
# Client - Front-end
docker run -it --rm --name tasknote-web -e CHECK="TRUE" -v ./client:/app tasknote-web:nightly
CURRENT_DIR=$(pwd)
if [ ! -f "$CURRENT_DIR/package.json" ]; then
cd "$CURRENT_DIR/client"
fi
npm ci
if [ $? -eq 1 ]; then
echo "Issues when installing dependencies. Please review.."
exit 1
fi
echo "Running checks..."
echo "1/3 - Lint started..."
npm run lint:fix
if [ $? -eq 1 ]; then
echo "Issues when running lint. Please review.."
exit 1
fi
echo "2/3 - Build started..."
npm run build
if [ $? -eq 1 ]; then
echo "Issues when running build. Please review.."
exit 1
fi
echo "3/3 - Tests started..."
npm run test:no-watch
if [ $? -eq 1 ]; then
echo "Issues when running test. Please review.."
exit 1
fi
echo "You're good to go! Good job!"
exit 0
-100
View File
@@ -1,100 +0,0 @@
#!/bin/bash
if [ -z "${DEPLOY_DOMAIN}" ]; then
echo "DEPLOY_DOMAIN not defined."
exit 1
fi
if [ -z "${API_KEY}" ]; then
echo "API_KEY not defined."
exit 1
fi
if [ -z "${CLIENT_APPID}" ]; then
echo "CLIENT_APPID not defined."
exit 1
fi
if [ -z "${PROD_URL}" ]; then
echo "PROD_URL not defined."
exit 1
fi
echo "DEPLOY_DOMAIN=${DEPLOY_DOMAIN}"
echo "API_KEY=${API_KEY}"
echo "CLIENT_APPID=${CLIENT_APPID}"
echo "PROD_URL=${PROD_URL}"
echo "cURL version $(curl --version)"
echo -e "\nPre-deployment check..."
if ! curl -s -f "${PROD_URL}" > /dev/null ; then
echo "Prod environment is not healthy"
else
echo "Prod environment is healthy"
fi
echo -e "\nUpdating Docker image tag..."
response=$(curl -X POST \
"${DEPLOY_DOMAIN}/api/application.saveDockerProvider" \
--max-time 30 \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: ${API_KEY}" \
-d '{
"dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:prod-v359",
"applicationId": "'"${CLIENT_APPID}"'",
"username": "'"${GHCR_USERNAME}"'",
"password": "'"${GHCR_PASSWORD}"'",
"registryUrl": "ghcr.io"
}' \
-w "\n%{http_code}" \
-s)
echo -e "\nResponse: $response"
status_code=$(echo "$response" | tail -n1)
echo -e "\nStatus code: $status_code"
if [ "$status_code" != "200" ]; then
body=$(echo "$response" | sed '$d')
echo "Update failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Update succeeded!"
fi
echo -e "\nDeploying to prod (target: ${PROD_URL})..."
response=$(curl -X POST \
"${DEPLOY_DOMAIN}/api/application.deploy" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: ${API_KEY}" \
-d "{\"applicationId\":\"${CLIENT_APPID}\"}" \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
echo -e "\nStatus code: $status_code"
if [ "$status_code" != "200" ]; then
body=$(echo "$response" | sed '$d')
echo "Deployment failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Deployment succeeded!"
fi
echo -e "\nVerifying deployment..."
sleep 15
echo -e "\nPre-deployment check..."
if ! curl -s -f "${PROD_URL}" > /dev/null ; then
echo "Prod environment is not healthy"
else
echo "Prod environment is healthy"
fi
-68
View File
@@ -1,68 +0,0 @@
#!/bin/bash
if [ -z "${DEPLOY_DOMAIN}" ]; then
echo "DEPLOY_DOMAIN not defined."
exit 1
fi
if [ -z "${API_KEY}" ]; then
echo "API_KEY not defined."
exit 1
fi
if [ -z "${CLIENT_APPID}" ]; then
echo "CLIENT_APPID not defined."
exit 1
fi
if [ -z "${STAGE_URL}" ]; then
echo "STAGE_URL not defined."
exit 1
fi
echo "DEPLOY_DOMAIN=${DEPLOY_DOMAIN}"
echo "API_KEY=${API_KEY}"
echo "CLIENT_APPID=${CLIENT_APPID}"
echo "STAGE_URL=${STAGE_URL}"
echo "cURL version $(curl --version)"
echo -e "\nPre-deployment check..."
if ! curl -s -f "${STAGE_URL}" > /dev/null ; then
echo "Stage environment is not healthy"
else
echo "Stage environment is healthy"
fi
echo -e "\nDeploying to stage (target: ${STAGE_URL})..."
response=$(curl -X POST \
"${DEPLOY_DOMAIN}/api/application.deploy" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: ${API_KEY}" \
-d "{\"applicationId\":\"${CLIENT_APPID}\"}" \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
echo -e "\nStatus code: $status_code"
if [ "$status_code" != "200" ]; then
body=$(echo "$response" | sed '$d')
echo "Deployment failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Deployment succeeded!"
fi
echo -e "\nVerifying deployment..."
sleep 15
echo -e "\nPre-deployment check..."
if ! curl -s -f "${STAGE_URL}" > /dev/null ; then
echo "Stage environment is not healthy"
else
echo "Stage environment is healthy"
fi
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
# Checks if a given image and tag are present locally to prevent running when it's not
TARGET="$1"
IS=$(docker images $TARGET:candidate | wc -l)
if [ $IS -eq 0 ]; then
echo "Image $TARGET:candidate not found locally, please build it"
exit 1;
fi
echo "Image $TARGET:candidate found"
exit 0
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# Check if a given service is not running
# Return 0 (success) if it's not
TARGET="$1"
echo "Check for running image for $TARGET"
IS=$(docker ps --filter name=$TARGET --filter status=running | grep $TARGET | wc -l)
if [ $IS -eq 0 ]; then
echo "Service $TARGET is not running"
exit 0
fi
echo "Service $TARGET is running"
exit 1
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
TARGET="$1"
echo "Check for running image for $TARGET"
IS=$(docker ps --filter name=$TARGET --filter status=running | grep $TARGET | wc -l)
if [ $IS -eq 1 ]; then
echo "Service $TARGET is running"
exit 0
fi
echo "Service $TARGET not running"
exit 1
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
if [ ! -f "server/.env" ]; then
echo "No env file found for front-end. Creating one for you.."
bash tools/run-create-env.sh "front"
else
echo "Env file in place. Moving on.."
fi
echo "Getting env vars and making them visible"
cd client/
export $(cat .env | xargs)
echo "Done!"
cd ..
docker build --file client/Dockerfile.dev --tag client:nightly .
if [ $? -eq 1 ]; then
echo "Issues when building Docker image. Please review.."
exit 1
fi
SERVER_HOST="http://"$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tasknote-api):8585"
if [ "$SERVER_HOST" == "http://:8585" ]; then
echo "Back-end tasknote-api not running. Make sure to run it before starting the web app."
exit 0
fi
echo "SERVER_HOST=$SERVER_HOST"
docker run -d --rm \
--name client \
-p 5000:5000 \
-e VITE_BACKEND_SERVER="$SERVER_HOST" \
-e VITE_BUILD="$VITE_BUILD" \
-v ./client:/app \
client:nightly
-28
View File
@@ -1,28 +0,0 @@
#!/bin/bash
if [ ! -f "server/.env" ]; then
echo "No env file found for back-end. Creating one for you.."
bash tools/run-create-env.sh "back"
else
echo "Env file in place. Moving on.."
fi
echo "Getting env vars and making them visible"
cd server/
export $(cat .env | xargs)
echo "Done!"
cd ..
echo "Starting Postgres DB..."
docker run -d --rm \
--name db \
-p 5432:5432 \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-e PGDATA=/tmp \
-v ./data:/tmp \
postgres:15.8-bookworm
echo "Done!"
-34
View File
@@ -1,34 +0,0 @@
#!/bin/bash
VERSION="$1"
echo "Received version $VERSION"
if [ ! -f "server/.env" ]; then
echo "No env file found for back-end. Creating one for you.."
bash tools/run-create-env.sh "back"
else
echo "Env file in place. Moving on.."
fi
echo "Getting env vars and making them visible"
cd server/
export $(cat .env | xargs)
echo "Done!"
cd ..
DB_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' db)
docker run -d --rm \
--name server \
-p 8585:8585 \
-p 5005:5005 \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-e POSTGRES_PORT=$POSTGRES_PORT \
-e POSTGRES_HOST=$DB_HOST \
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
$VERSION
-33
View File
@@ -1,33 +0,0 @@
#!/bin/bash
if [ ! -f "server/.env" ]; then
echo "No env file found for back-end. Creating one for you.."
bash tools/run-create-env.sh "back"
else
echo "Env file in place. Moving on.."
fi
echo "Getting env vars and making them visible"
cd server/
export $(cat .env | xargs)
echo "Done!"
cd ..
docker build --file server/Dockerfile.dev --tag tasknote-api:nightly .
DB_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' db)
docker run -d --rm \
--name tasknote-api \
-p 8585:8585 \
-p 5005:5005 \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-e POSTGRES_PORT=$POSTGRES_PORT \
-e POSTGRES_HOST=$DB_HOST \
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
-v ./server:/app \
tasknote-api:nightly