Compare commits

..
Author SHA1 Message Date
github-actions[bot] 41f7dabbf0 chore: bump api version to 11 [skip ci] 2026-03-02 20:45:13 +00:00
rmcamposandGitHub 5041ebeeff feat: fix backend issues with state and memory leak (#23)
* feat: fix backend issues with state and memory leak

* chore: address copilot review issues

* chore: address copilot review round 2

* test: fix test cases
2026-03-02 17:44:29 -03:00
github-actions[bot] fa8c84bb38 chore: bump api version to 10 [skip ci] 2026-02-28 16:08:55 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
c3115bc002 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>
2026-02-28 13:08:23 -03:00
rmcampos 91e48f2cbb chore: drop dokploy deploy for app 2026-02-27 19:27:09 -03:00
github-actions[bot] 4217ea11c8 chore: bump api version to 9 [skip ci] 2026-02-27 22:14:21 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
f41e34486a Add Source and Copy buttons to note markdown modal (#21)
* Initial plan

* Add Source and Copy buttons to ModalMarkdown for note source code view

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

* feat: add healthcheck do native image

---------

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>
2026-02-27 19:13:53 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
29812e8175 fix(readme): replace stale Sonar badges with GitHub Actions CI badges (#15)
* Initial plan

* fix: update README badges - remove Sonar badges and add GitHub Actions CI badges

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

* chore: drop dokploy from backend build

* docs: udpate README

---------

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>
2026-02-27 15:32:31 -03:00
github-actions[bot] 0444ad5a5d chore: bump api version to 8 [skip ci] 2026-02-27 18:10:34 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
89eb15d714 Upgrade backend to Spring Boot 4.0.3 and Java 25 (#20)
* Initial plan

* feat: upgrade to Spring Boot 4.0.3 and Java 25

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

* chore: fix google check style issues and drop unused annotations

---------

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>
2026-02-27 15:10:05 -03:00
rmcampos 08ed7c485e chore: drop claude from repo 2026-02-27 13:58:51 -03:00
86 changed files with 1084 additions and 912 deletions
-44
View File
@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
-2
View File
@@ -75,5 +75,3 @@ jobs:
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
- name: Trigger Dokploy deployment
run: curl -X POST "${{ vars.DOKPLOY_WEBHOOK_APP }}"
+3 -5
View File
@@ -31,7 +31,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
cache: 'maven'
- name: Increment version in pom.xml
@@ -71,7 +71,8 @@ jobs:
working-directory: ./server
run: |
./mvnw -Pnative -DskipTests spring-boot:build-image \
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
- name: Tag and push Docker image
run: |
@@ -85,6 +86,3 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}"
git push origin api-v${{ steps.version.outputs.version }}
- name: Trigger Dokploy deployment
run: curl -X POST "${{ vars.DOKPLOY_WEBHOOK_API }}"
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
cache: 'maven'
- name: Run Check Style
-113
View File
@@ -1,113 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Frontend (React/TypeScript)
```bash
cd client
npm start # Start development server on port 5000
npm run build # Build production bundle (TypeScript compilation + Vite build)
npm run preview # Preview production build
npm test # Run Vitest tests
npm run test:coverage # Run tests with coverage report
npm run lint # Run ESLint
npm run lint:fix # Run ESLint with auto-fix
```
### Backend (Java/Spring Boot)
```bash
cd server
./mvnw spring-boot:run # Start development server
./mvnw clean compile # Compile Java sources
./mvnw test # Run unit tests only
./mvnw clean verify -Ptests # Run all tests (unit + integration) with coverage
./mvnw spring-boot:build-image # Build Docker image
./mvnw clean verify -Pnative # Build GraalVM native image
```
### Docker Development Environment
```bash
bash tools/run-docker-db.sh # Start PostgreSQL database
bash tools/run-docker-server.sh # Start backend in Docker
bash tools/run-docker-client.sh # Start frontend in Docker
bash tools/check-frontend.sh # Run frontend quality checks
bash tools/check-backend.sh # Run backend quality checks
```
## Architecture
### Monorepo Structure
- `client/` - React TypeScript frontend (Vite + Vitest)
- `server/` - Java Spring Boot REST API
- `tools/` - Development and deployment scripts
- `docker-compose.dev.yml` / `docker-compose.prod.yml` - Multi-service development and production environments
- `Taskfile.yml` - Task automation for docker builds and workflows
### Frontend Architecture (client/)
- **Framework**: React 19 with React Router v7
- **State Management**: React Context API for authentication and sidebar state
- **Authentication**: JWT tokens stored in localStorage with automatic refresh (2-minute intervals)
- **Routing**: Dynamic router configuration based on auth status (signed vs not-signed routes)
- **Internationalization**: i18next with support for English, Portuguese, Russian, Spanish
- **Styling**: Bootstrap 5 + SCSS with dark/light theme support
- **Testing**: Vitest with React Testing Library and coverage reporting
- **API Layer**: Centralized API service in `src/api-service/api.ts` with automatic auth headers
### Backend Architecture (server/)
- **Framework**: Spring Boot 3.5.9 with Java 21
- **Security**: Spring Security with JWT authentication
- **Database**: PostgreSQL with JPA/Hibernate and Flyway migrations
- **Testing**: Separate unit tests and integration tests with 75% coverage requirement
- **Build Options**: Traditional JAR or GraalVM native image compilation
### Key Components
**Authentication Flow:**
1. User credentials → `/auth/sign-in` endpoint
2. Server responds with JWT token and user data
3. Token stored in localStorage, added to all API requests via Authorization header
4. Automatic token refresh every 2 minutes via `/rest/user-sessions/refresh`
5. Protected routes wrap authenticated pages, redirect on auth failure
**Database Schema:**
- Core entities: Users, Tasks, Notes, Task URLs, Notes URLs
- User management with password reset functionality
- Task completion tracking and user statistics
## Development Workflow
### Manual Setup
1. Start database: `bash tools/run-docker-db.sh`
2. Start backend: `bash tools/run-docker-server.sh` or `cd server && ./mvnw spring-boot:run`
3. Start frontend: `bash tools/run-docker-client.sh` or `cd client && npm start`
4. Access app at http://localhost:5000
### Using Taskfile
Alternatively, use the Taskfile.yml for automated workflows:
```bash
task dev-run # Start development environment (docker-compose.dev.yml)
task prod-up # Start production environment (docker-compose.prod.yml)
task docker-build-api # Build server Docker image
task docker-build-web # Build app Docker image
task -l # List all available tasks
```
### Quality Checks
Always run quality checks before submitting changes:
- Frontend: `bash tools/check-frontend.sh`
- Backend: `bash tools/check-backend.sh`
## Testing
### Frontend Testing
- **Framework**: Vitest + React Testing Library
- **Coverage**: Generated in `client/coverage/` directory
- **Run**: `cd client && npm test` or `npm run test:coverage`
### Backend Testing
- **Unit Tests**: Standard JUnit tests, run with `./mvnw test`
- **Integration Tests**: Files ending in `*IntTest.java`, require database
- **Coverage**: JaCoCo reports, 75% minimum requirement
- **Full Test Suite**: `./mvnw clean verify -Ptests` (includes checkstyle, coverage)
+22 -37
View File
@@ -1,21 +1,10 @@
# TaskNote
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
![Build Status](https://github.com/ricardo-campos-org/react-typescript-todolist/actions/workflows/main.yml/badge.svg)
### TaskNote API
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_server)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_server&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_server)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=br.com.tasknoteapp%3Aserver&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=br.com.tasknoteapp%3Aserver)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=br.com.tasknoteapp%3Aserver&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=br.com.tasknoteapp%3Aserver)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=br.com.tasknoteapp%3Aserver&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=br.com.tasknoteapp%3Aserver)
### TaskNote WebApp
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_client&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_client&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_client&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_client&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ricardo-campos-org_react-typescript-todolist_client&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ricardo-campos-org_react-typescript-todolist_client)
[![React App CI](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml)
[![Server API CI](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml)
[![Build and Push App Docker Image](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml)
[![Build and Push API Docker Image](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml)
## 📋 Table of Contents
@@ -49,17 +38,17 @@ The project was born from a month-long technical challenge and has since grown t
- **Responsive Design**: Mobile-first approach with Bootstrap 5 and dark/light theme support
- **Data Visualization**: Task completion charts and productivity analytics
- **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
- **Tagging System**: `#tag` support for better organization
- **Advanced Filters**: Enhanced search with date ranges, priority levels, and status filters
- **Collaboration**: Share tasks and notes with other users
- **Mobile App**: Native mobile applications for iOS and Android
- **Notifications**: Email and push notifications for due dates and reminders
## 🚀 Tech Stack
### Frontend (React TypeScript)
### Frontend (React & TypeScript)
- **Framework**: React 19 with TypeScript for type safety
- **Build Tool**: Vite for fast development and optimized production builds
- **Testing**: Vitest with React Testing Library and comprehensive coverage reporting
@@ -69,8 +58,8 @@ The project was born from a month-long technical challenge and has since grown t
- **Internationalization**: i18next with automatic language detection
- **API Client**: Centralized API service with automatic authentication headers
### Backend (Java Spring Boot)
- **Framework**: Spring Boot 3.5+ with Java 17
### Backend (Java & Spring Boot)
- **Framework**: Spring Boot 4.x.x with Java 25
- **Security**: Spring Security with JWT authentication and refresh tokens
- **Database**: PostgreSQL with JPA/Hibernate ORM
- **Migration**: Flyway for database schema versioning
@@ -84,19 +73,16 @@ The project was born from a month-long technical challenge and has since grown t
- **Containerization**: Docker and Docker Compose for development environment
- **Web Server**: Caddy for reverse proxy and SSL termination
- **CI/CD**: GitHub Actions with automated testing and quality gates
- **Code Analysis**: SonarCloud integration for security and maintainability
## 🏗️ Architecture
### Monorepo Structure
```
react-typescript-todolist/
tasknote/
├── client/ # React TypeScript frontend
├── server/ # Java Spring Boot REST API
├── angular/ # Alternative Angular frontend
├── tools/ # Development and deployment scripts
── docker-compose.yml
└── CLAUDE.md # AI assistant instructions
── docker-compose.yml
```
### Frontend Architecture
@@ -117,15 +103,15 @@ react-typescript-todolist/
### Prerequisites
- **Docker & Docker Compose** (recommended for easy setup)
- **Node.js 18+** and **npm** (for frontend development)
- **Java 17+** and **Maven 3.6+** (for backend development)
- **PostgreSQL 13+** (if running without Docker)
- **Node.js 20+** and **npm** (for frontend development)
- **Java 25+** and **Maven 3.6+** (for backend development)
- **PostgreSQL 15+** (if running without Docker)
### Quick Start with Docker
1. **Clone the repository**
```bash
git clone https://github.com/ricardo-campos-org/react-typescript-todolist.git
cd react-typescript-todolist
git clone https://github.com/rmcampos/tasknote.git
cd tasknote
```
2. **Start the database**
@@ -145,7 +131,6 @@ react-typescript-todolist/
5. **Access the application**
- Frontend: http://localhost:5000
- API Documentation: http://localhost:8080/swagger-ui.html
## 🛠️ Development
@@ -234,7 +219,7 @@ We welcome contributions from the community! This project follows the **Fork & M
1. **Fork the Project** on GitHub
2. **Clone your fork** locally
```bash
git clone https://github.com/YOUR_USERNAME/react-typescript-todolist.git
git clone https://github.com/YOUR_USERNAME/tasknote.git
```
3. **Create a feature branch**
```bash
@@ -269,9 +254,9 @@ For detailed setup instructions and development workflows, see [CONTRIBUTING.md]
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
- **GitHub**: [@ricardo-campos-org](https://github.com/ricardo-campos-org)
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardo-campos-org/)
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardompcampos/)
### About the Developer
Ricardo is a passionate full-stack developer with expertise in modern web technologies, cloud architecture, and agile development practices. This project showcases his skills in:
@@ -290,8 +275,8 @@ For questions, suggestions, or collaboration opportunities:
- **Email**: Contact via GitHub issues or discussions
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs) for quick questions
- **GitHub Issues**: [Create an issue](https://github.com/ricardo-campos-org/react-typescript-todolist/issues) for bugs or feature requests
- **GitHub Discussions**: [Join discussions](https://github.com/ricardo-campos-org/react-typescript-todolist/discussions) for general questions
- **GitHub Issues**: [Create an issue](https://github.com/rmcampos/tasknote/issues) for bugs or feature requests
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
## 📄 License
+1 -1
View File
@@ -12,7 +12,7 @@ tasks:
docker-build-api:
desc: Build the tasknote-api prod-ready docker image, tagging it as candidate
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
prod-up-web:
desc: Speed up the tasknote-web prod-like image, building it if required
-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 />
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, vi, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, vi, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import ModalMarkdown from '../../components/ModalMarkdown';
describe('ModalMarkdown Component', () => {
@@ -11,6 +11,14 @@ describe('ModalMarkdown Component', () => {
onHide: vi.fn(),
};
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
it('should render the modal with the correct title and markdown text', () => {
render(<ModalMarkdown {...props} />);
@@ -36,4 +44,64 @@ describe('ModalMarkdown Component', () => {
expect(screen.getByTestId('modal-header-title').innerHTML).toBe('No title');
});
it('should show Source and Copy buttons', () => {
render(<ModalMarkdown {...props} />);
expect(screen.getByTestId('modal-source-button')).toBeDefined();
expect(screen.getByTestId('modal-copy-button')).toBeDefined();
});
it('should toggle to source view when Source button is clicked', () => {
render(<ModalMarkdown {...props} />);
expect(screen.queryByTestId('markdown-source-view')).toBeNull();
fireEvent.click(screen.getByTestId('modal-source-button'));
expect(screen.getByTestId('markdown-source-view')).toBeDefined();
expect(screen.getByTestId('markdown-source-view').textContent).toBe('# Test Markdown');
});
it('should toggle back to rendered view when Source button is clicked again', () => {
render(<ModalMarkdown {...props} />);
fireEvent.click(screen.getByTestId('modal-source-button'));
expect(screen.getByTestId('markdown-source-view')).toBeDefined();
fireEvent.click(screen.getByTestId('modal-source-button'));
expect(screen.queryByTestId('markdown-source-view')).toBeNull();
});
it('should call clipboard writeText with markdownText when Copy button is clicked', async () => {
render(<ModalMarkdown {...props} />);
await act(async () => {
fireEvent.click(screen.getByTestId('modal-copy-button'));
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('# Test Markdown');
});
it('should show "Copied!" text after Copy button is clicked', async () => {
render(<ModalMarkdown {...props} />);
await act(async () => {
fireEvent.click(screen.getByTestId('modal-copy-button'));
});
await waitFor(() => {
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
});
});
it('should reset source view state when modal is closed', () => {
render(<ModalMarkdown {...props} />);
fireEvent.click(screen.getByTestId('modal-source-button'));
expect(screen.getByTestId('markdown-source-view')).toBeDefined();
fireEvent.click(screen.getByText('Close'));
expect(props.onHide).toHaveBeenCalled();
});
});
+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`
};
+47 -5
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import Markdown from 'react-markdown';
@@ -23,11 +23,31 @@ type Props = {
* @returns {React.ReactNode} the Markdown component rendered.
*/
const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
const [showSource, setShowSource] = useState<boolean>(false);
const [copied, setCopied] = useState<boolean>(false);
const handleToggleSource = () => setShowSource(prev => !prev);
const handleCopy = () => {
navigator.clipboard.writeText(props.markdownText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => {
// clipboard write failed silently; no state change
});
};
const handleHide = () => {
setShowSource(false);
setCopied(false);
props.onHide();
};
return props.show
? (
<Modal
show={props.show}
onHide={props.onHide}
onHide={handleHide}
backdrop="static"
keyboard={false}
size="xl"
@@ -42,12 +62,34 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
</Modal.Title>
</Modal.Header>
<Modal.Body className="markdown-modal">
<Markdown remarkPlugins={[remarkGfm]}>{props.markdownText}</Markdown>
{showSource
? (
<pre className="markdown-source" data-testid="markdown-source-view">
{props.markdownText}
</pre>
)
: (
<Markdown remarkPlugins={[remarkGfm]}>{props.markdownText}</Markdown>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="outline-secondary" onClick={props.onHide}>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<Button variant="outline-secondary" onClick={handleHide}>
Close
</Button>
<Button
variant={showSource ? 'info' : 'outline-info'}
onClick={handleToggleSource}
data-testid="modal-source-button"
>
Source
</Button>
<Button
variant="outline-primary"
onClick={handleCopy}
data-testid="modal-copy-button"
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</Modal.Footer>
</Modal>
)
@@ -154,3 +154,24 @@
text-decoration: line-through;
}
}
.markdown-source {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-size: 13px;
line-height: 1.6;
padding: 1em;
background-color: var(--bs-tertiary-bg);
border-radius: 6px;
overflow: auto;
max-height: 60vh;
}
@media (max-width: 576px) {
.markdown-source {
font-size: 12px;
padding: 0.75em;
max-height: 50vh;
}
}
+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;
+1 -1
View File
@@ -39,7 +39,7 @@ services:
ports:
- "8585:8585"
- "5005:5005"
image: maven:3.9.9-eclipse-temurin-21
image: maven:3.9.12-eclipse-temurin-25
entrypoint: './mvnw -ntp spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" -Dmaven.plugin.validation=VERBOSE'
working_dir: /app
volumes:
+11 -8
View File
@@ -6,13 +6,13 @@ services:
depends_on:
tasknote-api:
condition: service_healthy
image: docker.io/rmcampos/tasknote:app-latest
image: ghcr.io/rmcampos/tasknote/app:latest
build:
context: client
context: ./client
dockerfile: Dockerfile
ports: ["5000:5000"]
environment:
VITE_BACKEND_SERVER: http://tasknote-api:8585
VITE_BACKEND_SERVER: http://localhost:8585
networks:
- tasknote-network
@@ -33,16 +33,19 @@ services:
SECURITY_KEY: this-is-a-very-long-security-key-for-dev
MAILGUN_APIKEY: invalid-api-key-only-placeholder
ports: ["8585:8585"]
image: docker.io/rmcampos/tasknote:api-latest
build:
context: server
dockerfile: Dockerfile
image: ghcr.io/rmcampos/tasknote/api:latest
healthcheck:
test: ["CMD", "/layers/local_healthcheck/healthcheck/bin/healthcheck"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
networks:
- tasknote-network
tasknote-db:
container_name: tasknote-db
image: postgres:15.8-bookworm
image: postgres:16
environment:
POSTGRES_DB: tasknote
POSTGRES_USER: tasknoteuser
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build check.go
chmod +x check
mv check buildpacks/healthcheck/healthcheck
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
LAYERS_DIR="$1"
echo "CNB_BUILDPACK_DIR: ${CNB_BUILDPACK_DIR}"
echo "Contents of buildpack dir:"
ls -la "${CNB_BUILDPACK_DIR}"
BINARY_SOURCE="${CNB_BUILDPACK_DIR}/healthcheck"
if [ ! -f "${BINARY_SOURCE}" ]; then
echo "ERROR: binary not found at ${BINARY_SOURCE}"
exit 1
fi
LAYER_DIR="${LAYERS_DIR}/healthcheck"
mkdir -p "${LAYER_DIR}/bin"
cp "${BINARY_SOURCE}" "${LAYER_DIR}/bin/healthcheck"
chmod +x "${LAYER_DIR}/bin/healthcheck"
cat > "${LAYER_DIR}.toml" <<EOL
[types]
launch = true
build = false
cache = false
EOL
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exit 0
@@ -0,0 +1,11 @@
api = "0.7"
[buildpack]
id = "local/healthcheck"
version = "0.1.0"
name = "Healthcheck Binary Buildpack"
[[stacks]]
id = "io.buildpacks.stacks.jammy"
[[stacks]]
id = "*"
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
package main
import (
"net/http"
"os"
"time"
)
func main() {
client := http.Client{
Timeout: 5 * time.Second,
}
// Use 127.0.0.1 as the healthcheck runs inside the container
resp, err := client.Get("http://127.0.0.1:8585/health")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}
+27 -10
View File
@@ -5,13 +5,13 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.9</version>
<version>4.0.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>br.com.tasknoteapp</groupId>
<artifactId>server</artifactId>
<version>7</version>
<version>11</version>
<name>tasknote-api</name>
<description>Java backend REST API to serve TaskNote frontend client</description>
@@ -37,7 +37,7 @@
<!-- Properties -->
<properties>
<java.version>21</java.version>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<skip.integration.tests>true</skip.integration.tests>
@@ -80,7 +80,11 @@
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -105,6 +109,10 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
@@ -117,12 +125,12 @@
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<artifactId>spring-boot-starter-test-classic</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -153,8 +161,8 @@
<build>
<plugins>
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-maven-plugin</artifactId>
<version>${hibernate.version}</version>
<executions>
<execution>
@@ -165,7 +173,7 @@
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
<enableDirtyTracking>true</enableDirtyTracking>
<enableAssociationManagement>true</enableAssociationManagement>
<enableAssociationManagement>false</enableAssociationManagement>
</configuration>
</execution>
</executions>
@@ -351,6 +359,15 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>ghcr.io/rmcampos/tasknote/api:latest</name>
<buildpacks>
<buildpack>file://${project.basedir}/buildpacks/healthcheck</buildpack>
<buildpack>urn:cnb:builder:paketo-buildpacks/java-native-image</buildpack>
</buildpacks>
</image>
</configuration>
<executions>
<execution>
<goals>
+8
View File
@@ -0,0 +1,8 @@
[build]
builder = "paketobuildpacks/builder-jammy-tiny:latest"
[[build.buildpacks]]
uri = "buildpacks/healthcheck"
[[build.buildpacks]]
uri = "urn:cnb:builder:paketo-buildpacks/java-native-image"
-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
@@ -5,7 +5,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -23,8 +22,7 @@ public class CorsConfig implements WebMvcConfigurer {
*
* @param registry CorsRegistry instance.
*/
@SuppressWarnings("null")
public void addCorsMappings(@NonNull CorsRegistry registry) {
public void addCorsMappings(CorsRegistry registry) {
if (allowedOrigins != null && allowedOrigins.length > 0) {
logger.info("CORS policy allowed origins: {}", Arrays.asList(allowedOrigins));
@@ -14,6 +14,7 @@ import org.springframework.security.config.annotation.authentication.configurati
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@@ -51,12 +52,16 @@ public class SecurityConfig {
.permitAll()
.requestMatchers("/auth/**")
.permitAll()
.requestMatchers("/public/**")
.permitAll()
.requestMatchers("/rest/**")
.authenticated()
.anyRequest()
.permitAll())
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(
exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
@@ -1,6 +1,5 @@
package br.com.tasknoteapp.server.controller;
import br.com.tasknoteapp.server.entity.NoteEntity;
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
import br.com.tasknoteapp.server.request.NotePatchRequest;
import br.com.tasknoteapp.server.request.NoteRequest;
@@ -10,12 +9,12 @@ import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.NonNull;
import org.springframework.web.bind.annotation.DeleteMapping;
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;
@@ -49,7 +48,7 @@ public class NoteController {
* @throws NoteNotFoundException when note not found.
*/
@GetMapping("/{id}")
public NoteResponse getTaskById(@NonNull @PathVariable Long id) {
public NoteResponse getTaskById(@PathVariable Long id) {
return noteService.getNoteById(id);
}
@@ -63,7 +62,7 @@ public class NoteController {
*/
@PatchMapping("/{id}")
public ResponseEntity<NoteResponse> patchNote(
@PathVariable @NonNull Long id, @RequestBody @Valid NotePatchRequest noteRequest) {
@PathVariable Long id, @RequestBody @Valid NotePatchRequest noteRequest) {
return ResponseEntity.ok(noteService.patchNote(id, noteRequest));
}
@@ -77,8 +76,8 @@ public class NoteController {
*/
@PostMapping
public ResponseEntity<NoteResponse> postNotes(@RequestBody @Valid NoteRequest noteRequest) {
NoteEntity createdNote = noteService.createNote(noteRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(NoteResponse.fromEntity(createdNote));
NoteResponse createdNote = noteService.createNote(noteRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(createdNote);
}
/**
@@ -88,8 +87,32 @@ public class NoteController {
* @throws NoteNotFoundException when note not found.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteNote(@NonNull @PathVariable Long id) {
public ResponseEntity<Void> deleteNote(@PathVariable Long id) {
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));
}
}
@@ -9,7 +9,6 @@ import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.NonNull;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -48,7 +47,7 @@ public class TaskController {
* @throws TaskNotFoundException when task not found.
*/
@GetMapping("/{id}")
public TaskResponse getTaskById(@NonNull @PathVariable Long id) {
public TaskResponse getTaskById(@PathVariable Long id) {
return taskService.getTaskById(id);
}
@@ -62,7 +61,7 @@ public class TaskController {
*/
@PatchMapping("/{id}")
public ResponseEntity<TaskResponse> patchTask(
@PathVariable @NonNull Long id, @RequestBody @Valid TaskPatchRequest taskRequest) {
@PathVariable Long id, @RequestBody @Valid TaskPatchRequest taskRequest) {
return ResponseEntity.ok(taskService.patchTask(id, taskRequest));
}
@@ -86,7 +85,7 @@ public class TaskController {
* @throws TaskNotFoundException when task not found.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@NonNull @PathVariable Long id) {
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@@ -8,7 +8,6 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@@ -30,15 +29,18 @@ public class NoteEntity {
@ManyToOne(fetch = FetchType.LAZY)
private UserEntity user;
@OneToOne(mappedBy = "note", fetch = FetchType.LAZY)
private NoteUrlEntity noteUrl;
@Column(name = "tag", nullable = true, length = 30)
private String tag;
@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;
}
@@ -71,14 +73,6 @@ public class NoteEntity {
this.user = user;
}
public NoteUrlEntity getNoteUrl() {
return noteUrl;
}
public void setNoteUrl(NoteUrlEntity noteUrl) {
this.noteUrl = noteUrl;
}
public String getTag() {
return tag;
}
@@ -95,6 +89,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) {
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.Collection;
@@ -41,9 +40,6 @@ public class UserEntity implements UserDetails {
@Column(name = "name", length = 20)
private String name;
@OneToMany(mappedBy = "user")
private List<TaskEntity> tasks;
@Column(name = "email_confirmed_at", nullable = true)
private LocalDateTime emailConfirmedAt;
@@ -147,14 +143,6 @@ public class UserEntity implements UserDetails {
this.name = name;
}
public List<TaskEntity> getTasks() {
return tasks;
}
public void setTasks(List<TaskEntity> tasks) {
this.tasks = tasks;
}
public LocalDateTime getEmailConfirmedAt() {
return emailConfirmedAt;
}
@@ -7,7 +7,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -19,9 +18,9 @@ public class HeaderVersionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
response.setHeader("X-BUILD-INFO", appVersionService.getVersion());
filterChain.doFilter(request, response);
@@ -9,7 +9,6 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -28,9 +27,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
@@ -4,14 +4,12 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.aot.hint.ProxyHints;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
/** This class creates a RuntimeHint for the ServletRequest class. */
public class HttpServletRequestRuntimeHint implements RuntimeHintsRegistrar {
@Override
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
try {
ProxyHints proxies = hints.proxies();
proxies.registerJdkProxy(HttpServletRequest.class);
@@ -7,8 +7,6 @@ import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
/**
* This class creates RuntimeHints for JJWT exceptions to ensure they are available at runtime in
@@ -20,7 +18,7 @@ public class JjwtRuntimeHints {
static class JjwtHintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Register JJWT exceptions for reflection
hints
.reflection()
@@ -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,10 @@ public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
List<NoteEntity> findAllByUser_id(Long userId);
Optional<NoteEntity> findByShareToken(String shareToken);
Optional<NoteEntity> findByIdAndUser_id(Long id, Long userId);
@Query(
"select n from NoteEntity n where (upper(n.title) like %?1% or upper(n.description) like"
+ " %?1%) and n.user.id = ?2")
@@ -1,10 +1,16 @@
package br.com.tasknoteapp.server.repository;
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** This interface represents a note url repository, for database access. */
public interface NoteUrlRepository extends JpaRepository<NoteUrlEntity, Long> {
Optional<NoteUrlEntity> findByNote_id(Long noteId);
List<NoteUrlEntity> findAllByNote_idIn(List<Long> noteIds);
void deleteByNote_id(Long noteId);
}
@@ -12,6 +12,8 @@ public interface UserPwdLimitRepository extends JpaRepository<UserPwdLimitEntity
List<UserPwdLimitEntity> findAllByUser_id(Long userId, Sort sort);
List<UserPwdLimitEntity> findTop3ByUser_idOrderByWhenHappenedDesc(Long userId);
@Modifying
@Query("delete UserPwdLimitEntity u where u.user.id = ?1")
void deleteAllForUser(Long userId);
@@ -1,13 +1,12 @@
package br.com.tasknoteapp.server.response;
import br.com.tasknoteapp.server.entity.NoteEntity;
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
import br.com.tasknoteapp.server.util.TimeAgoUtil;
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.
@@ -15,9 +14,7 @@ public record NoteResponse(
* @param entity The NoteEntity source data.
* @return NoteResponse instance with all note data and urls, if any.
*/
public static NoteResponse fromEntity(NoteEntity entity) {
NoteUrlEntity noteUrl = entity.getNoteUrl();
String url = Objects.isNull(noteUrl) ? null : noteUrl.getUrl();
public static NoteResponse fromEntity(NoteEntity entity, String url) {
String timeAgoFmt = TimeAgoUtil.format(entity.getLastUpdate());
return new NoteResponse(
@@ -26,6 +23,8 @@ public record NoteResponse(
entity.getDescription(),
url,
timeAgoFmt,
entity.getTag());
entity.getTag(),
entity.isShared(),
entity.getShareToken());
}
}
@@ -6,7 +6,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.info.BuildProperties;
import org.springframework.context.event.EventListener;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
/** Service to retrieve application version information. */
@@ -22,7 +21,7 @@ public class AppVersionService {
*
* @param buildProperties the build properties injected by Spring Boot
*/
public AppVersionService(@Autowired(required = false) @Nullable BuildProperties buildProperties) {
public AppVersionService(@Autowired(required = false) BuildProperties buildProperties) {
this.buildProperties = buildProperties;
}
@@ -36,8 +36,6 @@ import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.env.Environment;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -513,8 +511,10 @@ public class AuthService {
}
private void checkLoginAttemptLimit(Long userId) {
Sort sort = Sort.by(Direction.DESC, "whenHappened");
List<UserPwdLimitEntity> userPwdList = userPwdLimitRepository.findAllByUser_id(userId, sort);
// Fetch only the 3 most recent failed attempts to avoid loading unbounded rows for
// targeted/brute-forced accounts.
List<UserPwdLimitEntity> userPwdList =
userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(userId);
logger.warn("login count attempt for user {}: {}", userId, userPwdList.size());
@@ -8,12 +8,13 @@ import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwd;
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm;
import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -53,7 +54,11 @@ public class MailgunEmailService {
this.senderEmail = sender;
this.targetEnv = targetEnv;
this.restTemplate =
templateBuilder.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey)).build();
templateBuilder
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(10))
.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey))
.build();
}
/**
@@ -4,7 +4,6 @@ import br.com.tasknoteapp.server.entity.NoteEntity;
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
import br.com.tasknoteapp.server.exception.TaskNotFoundException;
import br.com.tasknoteapp.server.repository.NoteRepository;
import br.com.tasknoteapp.server.repository.NoteUrlRepository;
import br.com.tasknoteapp.server.request.NotePatchRequest;
@@ -14,11 +13,13 @@ import br.com.tasknoteapp.server.util.AuthUtil;
import jakarta.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
/** This class implements the NoteService interface methods. */
@@ -67,35 +68,39 @@ public class NoteService {
List<NoteEntity> notes = noteRepository.findAllByUser_id(user.getId());
logger.info(notes.size() + " notes found!");
return notes.stream().map(NoteResponse::fromEntity).toList();
return getNotesUrl(notes);
}
/**
* Get a note by its id.
*
* @param noteId The task id in the database.
* @return {@link NoteResponse} with the found task or throw a {@link TaskNotFoundException}.
* @param noteId The note id in the database.
* @return {@link NoteResponse} with the found note or throw a {@link NoteNotFoundException}.
*/
public NoteResponse getNoteById(@NonNull Long noteId) {
public NoteResponse getNoteById(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Get note " + noteId + " to user " + user.getId());
Optional<NoteEntity> task = noteRepository.findById(noteId);
if (task.isEmpty()) {
Optional<NoteEntity> note = noteRepository.findById(noteId);
if (note.isEmpty()) {
throw new NoteNotFoundException();
}
if (!note.get().getUser().getId().equals(user.getId())) {
throw new NoteNotFoundException();
}
logger.info("Note found! Id " + noteId);
return NoteResponse.fromEntity(task.get());
return NoteResponse.fromEntity(note.get(), getNoteUrl(noteId));
}
/**
* Create a note for the user.
*
* @param noteRequest The note content.
* @return {@link NoteEntity} created in the database
* @return {@link NoteResponse} with created note data.
*/
public NoteEntity createNote(NoteRequest noteRequest) {
public NoteResponse createNote(NoteRequest noteRequest) {
UserEntity user = getCurrentUser();
logger.info("Creating note to user " + user.getId());
@@ -110,13 +115,14 @@ public class NoteService {
logger.info("Note created! Id " + created.getId());
String savedUrl = null;
if (!Objects.isNull(noteRequest.url()) && !noteRequest.url().isEmpty()) {
NoteUrlEntity urlEntity = saveUrl(note, noteRequest.url());
note.setNoteUrl(urlEntity);
NoteUrlEntity urlEntity = saveUrl(created, noteRequest.url());
savedUrl = urlEntity.getUrl();
}
logger.info("Finished note creation!");
return created;
return NoteResponse.fromEntity(created, savedUrl);
}
/**
@@ -127,12 +133,12 @@ public class NoteService {
* @return {@link NoteResponse} containing the updated note.
*/
@Transactional
public NoteResponse patchNote(@NonNull Long noteId, NotePatchRequest patch) {
public NoteResponse patchNote(Long noteId, NotePatchRequest patch) {
UserEntity user = getCurrentUser();
logger.info("Patching task " + noteId + " to user " + user.getId());
Optional<NoteEntity> note = noteRepository.findById(noteId);
Optional<NoteEntity> note = noteRepository.findByIdAndUser_id(noteId, user.getId());
if (note.isEmpty()) {
throw new NoteNotFoundException();
}
@@ -154,8 +160,7 @@ public class NoteService {
logger.info("URL deleted from task " + noteId);
if (!Objects.isNull(patch.url()) && !patch.url().isBlank()) {
NoteUrlEntity urlEntity = saveUrl(noteEntity, patch.url());
noteEntity.setNoteUrl(urlEntity);
saveUrl(noteEntity, patch.url());
} else {
logger.info("No urls to patch for task " + noteId);
}
@@ -165,7 +170,7 @@ public class NoteService {
logger.info("Note patched! Id " + patchedNote.getId());
return NoteResponse.fromEntity(patchedNote);
return NoteResponse.fromEntity(patchedNote, getNoteUrl(patchedNote.getId()));
}
/**
@@ -174,25 +179,20 @@ public class NoteService {
* @param noteId The note id from the database.
*/
@Transactional
public void deleteNote(@NonNull Long noteId) {
public void deleteNote(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Deleting note " + noteId + " to user " + user.getId());
Optional<NoteEntity> note = noteRepository.findById(noteId);
Optional<NoteEntity> note = noteRepository.findByIdAndUser_id(noteId, user.getId());
if (note.isEmpty()) {
throw new NoteNotFoundException();
}
NoteEntity noteEntity = note.get();
NoteUrlEntity noteUrl = noteEntity.getNoteUrl();
if (!Objects.isNull(noteUrl)) {
noteUrlRepository.delete(noteUrl);
logger.info("URL Deleted from task " + noteId);
} else {
logger.info("No urls to delete for task " + noteId);
}
noteUrlRepository.deleteByNote_id(noteId);
logger.info("URL deleted from task " + noteId);
noteRepository.delete(noteEntity);
@@ -213,7 +213,76 @@ public class NoteService {
List<NoteEntity> notes =
noteRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId());
logger.info(notes.size() + " tasks found!");
return notes.stream().map(NoteResponse::fromEntity).toList();
return getNotesUrl(notes);
}
/**
* 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.
*/
@Transactional
public NoteResponse shareNote(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Sharing note " + noteId + " for user " + user.getId());
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
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, getNoteUrl(noteEntity.getId()));
}
/**
* 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.findByIdAndUser_id(noteId, user.getId());
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, getNoteUrl(noteEntity.getId()));
}
/**
* 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(), getNoteUrl(noteOpt.get().getId()));
}
private UserEntity getCurrentUser() {
@@ -222,6 +291,22 @@ public class NoteService {
return authService.findByEmail(email).orElseThrow();
}
private String getNoteUrl(Long noteId) {
return noteUrlRepository.findByNote_id(noteId).map(NoteUrlEntity::getUrl).orElse(null);
}
private List<NoteResponse> getNotesUrl(List<NoteEntity> notes) {
List<Long> noteIds = notes.stream().map(NoteEntity::getId).toList();
if (noteIds.isEmpty()) {
return notes.stream().map(n -> NoteResponse.fromEntity(n, null)).toList();
}
List<NoteUrlEntity> urls = noteUrlRepository.findAllByNote_idIn(noteIds);
Map<Long, String> noteUrls =
urls.stream().collect(Collectors.toMap(nu -> nu.getNote().getId(), NoteUrlEntity::getUrl));
return notes.stream().map(n -> NoteResponse.fromEntity(n, noteUrls.get(n.getId()))).toList();
}
private NoteUrlEntity saveUrl(NoteEntity noteEntity, String url) {
NoteUrlEntity noteUrl = new NoteUrlEntity();
noteUrl.setUrl(url);
@@ -21,7 +21,6 @@ import java.util.Objects;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
/** This class contains the implementation for the Task Service class. */
@@ -80,7 +79,7 @@ public class TaskService {
* @param taskId The task id in the database.
* @return {@link TaskResponse} with the found task or throw a {@link TaskNotFoundException}.
*/
public TaskResponse getTaskById(@NonNull Long taskId) {
public TaskResponse getTaskById(Long taskId) {
UserEntity user = getCurrentUser();
logger.info("Get task " + taskId + " to user " + user.getId());
@@ -131,7 +130,7 @@ public class TaskService {
* @return {@link TaskResponse} with the updated content.
*/
@Transactional
public TaskResponse patchTask(@NonNull Long taskId, TaskPatchRequest patch) {
public TaskResponse patchTask(Long taskId, TaskPatchRequest patch) {
UserEntity user = getCurrentUser();
logger.info("Patching task " + taskId + " to user " + user.getId());
@@ -177,7 +176,7 @@ public class TaskService {
* @param taskId The task id in the database
*/
@Transactional
public void deleteTask(@NonNull Long taskId) {
public void deleteTask(Long taskId) {
UserEntity user = getCurrentUser();
logger.info("Deleting task " + taskId + " to user " + user.getId());
@@ -4,7 +4,6 @@ import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.repository.UserRepository;
import br.com.tasknoteapp.server.service.UserService;
import java.util.Optional;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@@ -12,24 +11,21 @@ import org.springframework.stereotype.Service;
@Service
class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserDetailsService cachedUserDetailsService;
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
this.cachedUserDetailsService =
email -> {
Optional<UserEntity> user = userRepository.findByEmail(email);
if (user.isEmpty()) {
throw new RuntimeException("User not found: " + email);
}
return user.get();
};
}
@Override
public UserDetailsService userDetailsService() {
return new UserDetailsService() {
@Override
public UserDetails loadUserByUsername(String email) {
Optional<UserEntity> user = userRepository.findByEmail(email);
if (user.isEmpty()) {
throw new RuntimeException("User not found: " + email);
}
return user.get();
}
};
return this.cachedUserDetailsService;
}
}
@@ -36,7 +36,8 @@ spring:
locations: classpath:db/migration
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
open-in-view: false
properties:
hibernate:
default_schema: tasknote
show-sql: true
show-sql: false
+2 -1
View File
@@ -36,7 +36,8 @@ spring:
locations: classpath:db/migration
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
open-in-view: false
properties:
hibernate:
default_schema: tasknote
show-sql: true
show-sql: false
@@ -0,0 +1,3 @@
ALTER TABLE tasknote.notes
ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN share_token VARCHAR(36) NULL;
@@ -15,15 +15,14 @@ import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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
@SuppressWarnings("null")
class AuthenticationControllerTest {
@Autowired private MockMvc mockMvc;
@@ -11,8 +11,8 @@ import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -27,7 +27,6 @@ class HomeControllerTest {
@MockitoBean private HomeService homeService;
@Test
@SuppressWarnings("null")
@DisplayName("Get task tags following the happy path it should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void getTasksTags_happyPath_shouldSucceed() throws Exception {
@@ -46,9 +45,8 @@ class HomeControllerTest {
}
@Test
@SuppressWarnings("null")
@DisplayName("Get task tags not authorized it should fail")
void getTasksTags_notAuthorized_shouldSucceed() throws Exception {
void getTasksTags_notAuthorized_shouldFail() throws Exception {
mockMvc
.perform(
get("/rest/home/tasks/tags")
@@ -8,10 +8,10 @@ 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;
import br.com.tasknoteapp.server.entity.NoteEntity;
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
import br.com.tasknoteapp.server.request.NotePatchRequest;
import br.com.tasknoteapp.server.request.NoteRequest;
@@ -23,8 +23,8 @@ import org.hamcrest.Matchers;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -32,7 +32,6 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class NoteControllerTest {
@Autowired private MockMvc mockMvc;
@@ -45,7 +44,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));
@@ -103,7 +102,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);
@@ -192,10 +198,8 @@ class NoteControllerTest {
void postNotes_happyPath_shouldSucceed() throws Exception {
NoteRequest request = new NoteRequest("Title", "Description", null, null);
NoteEntity entity = new NoteEntity();
entity.setId(1L);
entity.setTitle(request.title());
entity.setDescription(request.description());
NoteResponse entity = new NoteResponse(1L, request.title(), request.description(),
null, null, null, false, null);
when(noteService.createNote(request)).thenReturn(entity);
@@ -216,9 +220,9 @@ class NoteControllerTest {
.accept(MediaType.APPLICATION_JSON)
.content(payloadJson))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(entity.getId()))
.andExpect(jsonPath("$.title").value(entity.getTitle()))
.andExpect(jsonPath("$.description").value(entity.getDescription()))
.andExpect(jsonPath("$.id").value(entity.id()))
.andExpect(jsonPath("$.title").value(entity.title()))
.andExpect(jsonPath("$.description").value(entity.description()))
.andExpect(jsonPath("$.url", Matchers.nullValue()))
.andReturn();
}
@@ -316,4 +320,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();
}
}
@@ -21,8 +21,8 @@ import org.hamcrest.Matchers;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -30,7 +30,6 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class TaskControllerTest {
@Autowired private MockMvc mockMvc;
@@ -14,8 +14,8 @@ import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -23,7 +23,6 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class UserControllerTest {
@Autowired private MockMvc mockMvc;
@@ -13,8 +13,8 @@ import br.com.tasknoteapp.server.service.UserSessionService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -22,7 +22,6 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@SuppressWarnings("null")
class UserSessionControllerTest {
@Autowired private MockMvc mockMvc;
@@ -11,9 +11,9 @@ import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -9,9 +9,9 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
import org.springframework.test.context.jdbc.Sql;
@DataJpaTest
@@ -6,9 +6,9 @@ import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
import org.springframework.test.context.jdbc.Sql;
@DataJpaTest
@@ -34,17 +34,14 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.env.Environment;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@SuppressWarnings("null")
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock private UserRepository userRepository;
@@ -95,8 +92,6 @@ class AuthServiceTest {
entity.setEmailUuid(UUID.randomUUID());
when(userRepository.save(any())).thenReturn(entity);
when(jwtService.generateToken(any())).thenReturn("a1b2c3");
doNothing().when(mailgunEmailService).sendNewUser(any());
UserResponseWithToken token = authService.signUpNewUser(request);
@@ -204,8 +199,8 @@ class AuthServiceTest {
existing.setEmail(request.email());
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
Sort sort = Sort.by(Direction.DESC, "whenHappened");
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of());
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
.thenReturn(List.of());
when(authenticationManager.authenticate(any())).thenReturn(null);
when(jwtService.generateToken(existing)).thenReturn("a1b2c3");
@@ -244,8 +239,7 @@ class AuthServiceTest {
limit1.setWhenHappened(LocalDateTime.now().minusMinutes(1));
UserPwdLimitEntity limit2 = new UserPwdLimitEntity();
UserPwdLimitEntity limit3 = new UserPwdLimitEntity();
Sort sort = Sort.by(Direction.DESC, "whenHappened");
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort))
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
.thenReturn(List.of(limit1, limit2, limit3));
Assertions.assertThrows(
@@ -264,8 +258,8 @@ class AuthServiceTest {
existing.setId(919L);
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
Sort sort = Sort.by(Direction.DESC, "whenHappened");
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of());
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
.thenReturn(List.of());
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("Wrong"));
UserResponseWithToken token = authService.signInUser(request);
@@ -532,8 +526,6 @@ class AuthServiceTest {
existing.setEmail(email);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
doNothing().when(mailgunEmailService).sendNewUser(existing);
Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email));
verify(mailgunEmailService, times(0)).sendNewUser(existing);
}
@@ -13,14 +13,13 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
/** Test class generated by Copilot. I had to tweak it, to make it work. */
@SuppressWarnings("null")
@ExtendWith(MockitoExtension.class)
class MailgunEmailServiceTest {
@@ -31,14 +30,15 @@ class MailgunEmailServiceTest {
@BeforeEach
void setUp() {
when(restTemplateBuilder.connectTimeout(any())).thenReturn(restTemplateBuilder);
when(restTemplateBuilder.readTimeout(any())).thenReturn(restTemplateBuilder);
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
when(restTemplateBuilder.build()).thenReturn(restTemplate);
String apiKey = "abx123";
String domain = "domain.com";
String sender = "no-reply@domain.com";
String target = "development";
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
when(restTemplateBuilder.build()).thenReturn(restTemplate);
mailgunEmailService =
new MailgunEmailService(apiKey, domain, sender, target, restTemplateBuilder);
}
@@ -3,8 +3,8 @@ package br.com.tasknoteapp.server.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -28,7 +28,6 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@SuppressWarnings("null")
@ExtendWith(MockitoExtension.class)
class NoteServiceTest {
@@ -106,9 +105,9 @@ class NoteServiceTest {
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
when(noteUrlRepository.save(any(NoteUrlEntity.class))).thenReturn(new NoteUrlEntity());
NoteEntity createdNote = noteService.createNote(noteRequest);
NoteResponse createdNote = noteService.createNote(noteRequest);
assertEquals("Test Note", createdNote.getTitle());
assertEquals("Test Note", createdNote.title());
verify(noteRepository, times(1)).save(any(NoteEntity.class));
verify(noteUrlRepository, times(1)).save(any(NoteUrlEntity.class));
}
@@ -117,13 +116,14 @@ class NoteServiceTest {
void patchNote() {
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.findByIdAndUser_id(note.getId(), user.getId()))
.thenReturn(Optional.of(note));
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
NoteResponse patchedNote = noteService.patchNote(note.getId(), notePatchRequest);
assertEquals("Updated Note", patchedNote.title());
verify(noteRepository, times(1)).findById(note.getId());
verify(noteRepository, times(1)).findByIdAndUser_id(note.getId(), user.getId());
verify(noteRepository, times(1)).save(any(NoteEntity.class));
}
@@ -131,7 +131,8 @@ class NoteServiceTest {
void patchNote_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());
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
.thenReturn(Optional.empty());
Long noteId = note.getId();
assertThrows(
@@ -142,11 +143,12 @@ class NoteServiceTest {
void deleteNote() {
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.findByIdAndUser_id(note.getId(), user.getId()))
.thenReturn(Optional.of(note));
noteService.deleteNote(note.getId());
verify(noteRepository, times(1)).findById(note.getId());
verify(noteRepository, times(1)).findByIdAndUser_id(note.getId(), user.getId());
verify(noteRepository, times(1)).delete(note);
}
@@ -163,4 +165,86 @@ 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.findByIdAndUser_id(note.getId(), user.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.findByIdAndUser_id(note.getId(), user.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.findByIdAndUser_id(note.getId(), user.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.findByIdAndUser_id(note.getId(), user.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));
}
}
@@ -34,10 +34,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.Mock;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.mockito.junit.jupiter.MockitoExtension;
@SuppressWarnings("null")
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock TaskRepository taskRepository;
@@ -535,8 +534,6 @@ class TaskServiceTest {
String searchTerm = null;
when(taskRepository.findAllBySearchTerm(null, USER_ID)).thenReturn(List.of());
List<TaskResponse> responses = taskService.searchTasks(searchTerm);
assertNotNull(responses);
@@ -16,10 +16,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.mockito.junit.jupiter.MockitoExtension;
@SuppressWarnings("null")
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class UserSessionServiceTest {
// Test class generated by GitHub Copilot
@@ -44,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));
@@ -26,7 +26,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.util.ReflectionTestUtils;
@SuppressWarnings("null")
@ExtendWith(MockitoExtension.class)
class JwtServiceImplTest {
+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