Compare commits

..

2 Commits

Author SHA1 Message Date
dionmoustos 13f14caaa3 initial 2025-10-27 16:28:43 +10:30
dionmoustos 15431b69b3 initial 2025-10-27 16:28:03 +10:30
73 changed files with 708 additions and 4803 deletions
-29
View File
@@ -1,29 +0,0 @@
# Environment variables for development
# Copy this file to .env and update with your values
# Database Configuration
DB_URL=jdbc:postgresql://localhost:5432/mtgsearch
DB_USER=postgres
DB_PASSWORD=postgres
DB_MAX_POOL_SIZE=10
# JWT Configuration
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRATION=86400
# Application Configuration
APP_NAME=mtg-search
APP_VERSION=0.1.0
APP_ENVIRONMENT=development
# Server Configuration
SERVER_PORT=8080
SERVER_SERVLET_CONTEXT_PATH=/
# Frontend Configuration
VITE_API_URL=http://localhost:8080
VITE_APP_TITLE=MTG Search
# Logging
LOG_LEVEL=DEBUG
LOG_DIR=logs
+3
View File
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
+44 -32
View File
@@ -1,34 +1,46 @@
.gradle/
node_modules
HELP.md
.gradle
build/
out/
.idea/
*.iml
*.class
*.jar
*.war
*.nar
*.zip
*.tar.gz
*.rar
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
.DS_Store
*.log
logs/
target/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.settings/
*.sublime-workspace
*.sublime-project
coverage/
.nyc_output/
dev.properties
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Vaadin Ignore
src/main/frontend/generated/
### mtg json
/AllPrintings/
/AllPrintings.psql.zip
/AllPrintings.psql.zip.sha256
-470
View File
@@ -1,470 +0,0 @@
# Development Guide for MTG Search
## Local Development Environment Setup
### Prerequisites Installation
#### macOS
```bash
# Install Homebrew if not already installed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install Java 21
brew install --cask java
# Install Node.js
brew install node@20
# Install PostgreSQL
brew install postgresql
# Install Docker
brew install --cask docker
```
#### Ubuntu/Debian
```bash
# Install Java 21
sudo apt-get update
sudo apt-get install -y openjdk-21-jdk
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install PostgreSQL
sudo apt-get install -y postgresql postgresql-contrib
# Install Docker
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
```
#### Windows
1. Install Java 21 from [Oracle](https://www.oracle.com/java/technologies/downloads/)
2. Install Node.js from [nodejs.org](https://nodejs.org)
3. Install PostgreSQL from [postgresql.org](https://www.postgresql.org/download/windows/)
4. Install Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop)
### Quick Start with Startup Script
```bash
# Make script executable
chmod +x startup.sh
# Run the startup script
./startup.sh
```
### Manual Setup
#### Step 1: Start PostgreSQL
**Using Docker (Recommended):**
```bash
docker run --name mtgsearch-postgres \
-e POSTGRES_DB=mtgsearch \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:17-alpine
```
**Using Local PostgreSQL:**
```bash
# macOS
brew services start postgresql
# Linux
sudo systemctl start postgresql
# Windows
# Start from Services or GUI
```
#### Step 2: Verify Database Connection
```bash
psql -U postgres -h localhost -d mtgsearch -c "SELECT 1;"
```
#### Step 3: Build and Run Backend
```bash
cd backend
# Build with Gradle
../gradlew build
# Run the application
../gradlew bootRun
# The backend will start on http://localhost:8080
```
#### Step 4: Set Up and Run Frontend
```bash
cd frontend
# Install dependencies
npm install
# Start development server
npm run dev
# The frontend will start on http://localhost:5173
```
## Development Workflow
### Making Changes
#### Backend Changes
1. Edit Java files in `backend/src/main/java`
2. Spring Boot DevTools will auto-reload
3. Check logs: `tail -f logs/mtg-search.log`
#### Frontend Changes
1. Edit Vue files in `frontend/src`
2. Vite will hot-reload automatically
3. Check browser console for errors
### Testing
#### Backend Tests
```bash
cd backend
# Run all tests
../gradlew test
# Run specific test class
../gradlew test --tests AuthServiceTest
# Run with coverage
../gradlew test jacocoTestReport
```
#### Frontend Tests
```bash
cd frontend
# Run tests
npm run test
# Run with coverage
npm run test -- --coverage
# Watch mode
npm run test -- --watch
```
### Code Quality
#### Backend
```bash
cd backend
# Run checks (spotless, checkstyle, etc.)
../gradlew check
# Format code
../gradlew spotlessApply
```
#### Frontend
```bash
cd frontend
# Run linter
npm run lint
# Fix linting issues
npm run lint --fix
```
## Database Management
### Running Flyway Migrations
Migrations run automatically when the application starts. To manage migrations manually:
```bash
cd backend
# Show migration status
../gradlew flywayInfo
# Validate migrations
../gradlew flywayValidate
# Repair (use with caution)
../gradlew flywayRepair
# Clean schema (WARNING: deletes all data)
../gradlew flywayClean
```
### Creating New Migration
1. Create file: `backend/src/main/resources/db/migration/V{NUMBER}__Description.sql`
Example:
```sql
-- V2__Add_search_history_table.sql
CREATE TABLE IF NOT EXISTS search_history (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
search_query VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_search_history_user_id ON search_history(user_id);
```
## API Endpoints Testing
### Using curl
```bash
# Register a new user
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}'
# Login
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "SecurePassword123!"
}'
# Use token in requests
TOKEN=<your-jwt-token>
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/v1/protected-endpoint
```
### Using Swagger UI
Navigate to: http://localhost:8080/swagger-ui.html
## Debugging
### Backend Debugging
#### In IDE (IntelliJ IDEA)
1. Set breakpoints in your code
2. Run → Debug 'MtgSearchApplication'
3. Step through code
#### Remote Debugging
```bash
cd backend
../gradlew bootRun --args='--debug'
# Then connect remote debugger on port 5005
```
### Frontend Debugging
1. Open browser DevTools (F12 or Cmd+Option+I)
2. Check Console, Network, and Application tabs
3. Use Vue DevTools browser extension
### Logs
```bash
# Backend logs
tail -f logs/mtg-search.log
# Backend errors
tail -f logs/mtg-search-error.log
# Frontend browser console
# Open DevTools → Console tab
```
## Environment Variables
Create `.env` file in project root:
```env
# Database
DB_URL=jdbc:postgresql://localhost:5432/mtgsearch
DB_USER=postgres
DB_PASSWORD=postgres
# JWT
JWT_SECRET=development-secret-key
JWT_EXPIRATION=86400
# Application
SPRING_PROFILE=dev
# Frontend
VITE_API_URL=http://localhost:8080
```
## IDE Configuration
### IntelliJ IDEA
1. Open project root folder
2. File → Project Structure → Modules
3. Select backend module
4. Set Java 21 as SDK
5. Install Spring Boot plugin
6. Install Lombok plugin
### VS Code
1. Install extensions:
- Extension Pack for Java
- Spring Boot Extension Pack
- Vue 3 Extension
- TypeScript Vue Plugin
2. Configure Java home in settings
3. Install Node modules
## Useful Gradle Commands
```bash
# Build tasks
./gradlew build # Full build
./gradlew clean build # Clean and build
./gradlew build -x test # Build without tests
# Test tasks
./gradlew test # Run tests
./gradlew test --watch # Watch mode
# Development tasks
./gradlew bootRun # Run application
./gradlew bootRun --debug # Debug mode
# Code generation
./gradlew openApiGenerate # Generate from OpenAPI spec
./gradlew generateJooqCode # Generate jOOQ entities
# Database tasks
./gradlew flywayInfo # Show migration status
./gradlew flywayMigrate # Run migrations
```
## Useful npm Commands
```bash
# Development
npm run dev # Start dev server
npm run build # Build for production
npm run preview # Preview production build
# Testing
npm run test # Run tests
npm run test:ui # Run tests with UI
npm run test -- --watch # Watch mode
# Quality
npm run lint # Run linter
npm run type-check # Check TypeScript types
```
## Troubleshooting Common Issues
### "Address already in use" port 8080
```bash
# Find process using port 8080
lsof -i :8080
# Kill the process
kill -9 <PID>
```
### "Address already in use" port 5173
```bash
# Find process using port 5173
lsof -i :5173
# Kill the process
kill -9 <PID>
```
### PostgreSQL connection refused
```bash
# Check if PostgreSQL is running
psql --version
# Try to connect
psql -U postgres -h localhost
# If using Docker, check container
docker ps | grep postgres
docker logs mtgsearch-postgres
```
### Gradle build fails
```bash
# Clear gradle cache
rm -rf ~/.gradle/caches
# Clean and rebuild
./gradlew clean build --refresh-dependencies
```
### Frontend build fails
```bash
# Clear node_modules
rm -rf frontend/node_modules package-lock.json
# Reinstall dependencies
cd frontend && npm install
```
### Flyway migration fails
```bash
# Check migration status
cd backend && ../gradlew flywayInfo
# Validate migrations
../gradlew flywayValidate
# Start fresh (DELETES ALL DATA)
../gradlew flywayClean
../gradlew flywayMigrate
```
## Performance Profiling
### Backend
```bash
# Generate JFR recording
cd backend
../gradlew bootRun --jvmArgs="-XX:StartFlightRecording=filename=recording.jfr"
# Analyze with Java Flight Recorder
jmc recording.jfr
```
### Frontend
```bash
# Build with source maps
cd frontend
npm run build -- --sourcemap
# Use Chrome DevTools Performance tab
```
## Additional Resources
- [Spring Boot Documentation](https://spring.io/projects/spring-boot)
- [Vue 3 Documentation](https://vuejs.org)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Gradle Documentation](https://gradle.org/guides/)
- [Docker Documentation](https://docs.docker.com/)
- [GitLab CI/CD Documentation](https://docs.gitlab.com/ee/ci/)
-53
View File
@@ -1,53 +0,0 @@
# Multi-stage build for MTG Search
# Stage 1: Build backend and frontend
FROM eclipse-temurin:21.0.2-jdk as builder
WORKDIR /build
# Install Node.js
RUN apt-get update && apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# Copy gradle files
COPY gradle gradle
COPY gradlew .
COPY build.gradle.kts .
COPY settings.gradle.kts .
COPY gradle.properties .
# Copy backend source
COPY backend backend
# Copy frontend source
COPY frontend frontend
# Build backend
WORKDIR /build/backend
RUN ../gradlew build -x test
# Build frontend
WORKDIR /build/frontend
RUN npm ci && npm run build
# Stage 2: Runtime
FROM eclipse-temurin:21.0.2-jre
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy built backend jar
COPY --from=builder /build/backend/build/libs/*.jar app.jar
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/api/v1/auth/health || exit 1
# Run application
ENTRYPOINT ["java", "-jar", "app.jar"]
Vendored
-189
View File
@@ -1,189 +0,0 @@
pipeline {
agent any
options {
timestamps()
timeout(time: 1, unit: 'HOURS')
buildDiscarder(logRotator(numToKeepStr: '10'))
}
parameters {
booleanParam(name: 'DEPLOY_TO_STAGING', defaultValue: false, description: 'Deploy to staging')
booleanParam(name: 'DEPLOY_TO_PRODUCTION', defaultValue: false, description: 'Deploy to production')
}
environment {
// Docker registry configuration
REGISTRY = credentials('docker-registry-url')
REGISTRY_USER = credentials('docker-registry-user')
REGISTRY_PASSWORD = credentials('docker-registry-password')
DOCKER_IMAGE = "${REGISTRY}/${GIT_COMMIT.take(8)}"
DOCKER_IMAGE_LATEST = "${REGISTRY}:latest"
// Database configuration for builds
DB_URL = "jdbc:postgresql://postgres:5432/mtgsearch_build"
DB_USER = "postgres"
DB_PASSWORD = "postgres"
POSTGRES_DB = "mtgsearch_build"
// SonarQube configuration
SONAR_USER_HOME = "${WORKSPACE}/.sonar"
GIT_DEPTH = "0"
// Build cache
GRADLE_USER_HOME = "${WORKSPACE}/.gradle"
}
stages {
stage('Build Java & Frontend') {
agent any
steps {
script {
echo "Building Java and Frontend..."
}
sh '''
chmod +x ./gradlew
./gradlew clean build -x test --build-cache
'''
}
post {
success {
archiveArtifacts artifacts: 'backend/build/libs/**/*.jar,frontend/dist/**/*', fingerprint: true
}
}
}
stage('Run Unit Tests') {
agent any
steps {
script {
echo "Running unit tests..."
}
sh '''
chmod +x ./gradlew
./gradlew test --build-cache
'''
}
post {
always {
junit 'backend/build/test-results/test/**/*.xml'
}
}
}
stage('Run Integration Tests') {
agent {
docker {
image 'eclipse-temurin:21-jdk'
args '--network=host'
}
}
when {
anyOf {
branch 'main'
branch 'develop'
any echo "Running integration tests..."
}
sh '''
chmod +x ./gradlew
./gradlew integrationTest --build-cache
'''
}
post {
always {
junit 'backend/build/test-results/integrationTest/**/*.xml'
}
}
}
stage('Code Quality Checks') {
agent {
docker {
image 'eclipse-temurin:21-jdk'
args '--network=host'
}
}any script {
echo "Running code quality checks..."
}
sh '''
chmod +x ./gradlew
./gradlew check -x test
'''
}
post {
failure {
echo "Quality checks failed - continuing pipeline with warning"
}
}
}
stage('SonarQube Analysis') {
withSonarQubeEnv() {
sh "./gradlew sonar -Dsonar.projectKey=MTGSearch -Dsonar.projectName='MTGSearch' -Dsonar.host.url=https://sonarqube.moustos.net -Dsonar.token=sqp_452a7bbe843fafe78b1a45efaf7ffb86ab6ba881"
}
}
stage('Deploy to Staging') {
when {
allOf {
branch 'develop'
expression { params.DEPLOY_TO_STAGING == true }
}
}
steps {
script {
echo "Deploying to staging environment..."
input(id: 'DeployToStaging', message: 'Deploy to staging?', ok: 'Deploy')
}
sh '''
curl -X POST "${STAGING_DEPLOY_WEBHOOK}" \
-H "Content-Type: application/json" \
-d '{"image":"${DOCKER_IMAGE}"}'
echo "Deployment to staging triggered"
'''
}
}
stage('Deploy to Production') {
when {
anyOf {
allOf {
branch 'main'
expression { params.DEPLOY_TO_PRODUCTION == true }
}
buildingTag()
}
}
steps {
script {
echo "Deploying to production environment..."
input(id: 'DeployToProd', message: 'Deploy to production?', ok: 'Deploy')
}
sh '''
curl -X POST "${PROD_DEPLOY_WEBHOOK}" \
-H "Content-Type: application/json" \
-d '{"image":"${DOCKER_IMAGE}"}'
echo "Deployment to production triggered"
'''
}
}
}
post {
always {
cleanWs()
}
failure {
script {
echo "Pipeline failed! Check logs for details."
// You can add notification logic here
}
}
success {
script {
echo "Pipeline completed successfully!"
// You can add notification logic here
}
}
}
}
-117
View File
@@ -1,117 +0,0 @@
# MTG Search - Makefile for common development tasks
.PHONY: help build test run clean docker-build docker-run docker-stop frontend-install frontend-build backend-build
help:
@echo "MTG Search - Available Commands"
@echo "================================"
@echo "make build - Build both backend and frontend"
@echo "make backend-build - Build backend only"
@echo "make frontend-build - Build frontend only"
@echo "make test - Run all tests"
@echo "make backend-test - Run backend tests"
@echo "make frontend-test - Run frontend tests"
@echo "make run - Run backend server (requires frontend built)"
@echo "make dev - Run frontend dev server and backend"
@echo "make clean - Clean all build directories"
@echo "make docker-build - Build Docker image"
@echo "make docker-run - Start services with docker-compose"
@echo "make docker-stop - Stop all docker-compose services"
@echo "make docker-clean - Remove all containers and volumes"
@echo "make lint - Run code quality checks"
@echo "make format - Format code"
@echo "make db-init - Initialize database"
build: backend-build frontend-build
@echo "✓ Build complete"
backend-build:
@echo "Building backend..."
cd backend && ../gradlew clean build -x test
@echo "✓ Backend build complete"
frontend-build:
@echo "Building frontend..."
cd frontend && npm install && npm run build
@echo "✓ Frontend build complete"
frontend-install:
@echo "Installing frontend dependencies..."
cd frontend && npm install
@echo "✓ Frontend dependencies installed"
test: backend-test
@echo "✓ All tests passed"
backend-test:
@echo "Running backend tests..."
cd backend && ../gradlew test
frontend-test:
@echo "Running frontend tests..."
cd frontend && npm run test
run:
@echo "Starting backend server..."
cd backend && ../gradlew bootRun
dev: frontend-install
@echo "Starting development environment..."
@echo "Frontend: http://localhost:5173"
@echo "Backend: http://localhost:8080"
@echo ""
@trap 'kill %1 %2' SIGINT; \
(cd frontend && npm run dev) & \
(cd backend && ../gradlew bootRun) & \
wait
clean:
@echo "Cleaning build directories..."
cd backend && ../gradlew clean
cd frontend && rm -rf node_modules dist
rm -rf logs
@echo "✓ Clean complete"
docker-build:
@echo "Building Docker image..."
docker build -t mtgsearch:latest .
@echo "✓ Docker build complete"
docker-run:
@echo "Starting Docker services..."
docker-compose up --build
@echo "✓ Services started"
docker-stop:
@echo "Stopping Docker services..."
docker-compose down
@echo "✓ Services stopped"
docker-clean:
@echo "Cleaning Docker resources..."
docker-compose down -v
docker image rm mtgsearch:latest
@echo "✓ Docker cleanup complete"
lint:
@echo "Running linters..."
cd backend && ../gradlew check -x test
cd frontend && npm run lint
@echo "✓ Linting complete"
format:
@echo "Formatting code..."
cd frontend && npm run lint
@echo "✓ Formatting complete"
db-init:
@echo "Initializing database..."
docker run --name mtgsearch-postgres \
-e POSTGRES_DB=mtgsearch \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:17-alpine
@echo "✓ Database initialized on localhost:5432"
.DEFAULT_GOAL := help
-513
View File
@@ -1,513 +0,0 @@
# MTG Search - Magic The Gathering Card Search Platform
A full-stack web application for searching and exploring Magic The Gathering cards with user authentication, built with Spring Boot, Vue.js, PostgreSQL, and deployed with Docker.
## Architecture Overview
```
├── Backend (Java 21, Spring Boot 3.2.5)
│ ├── REST API with OpenAPI/Swagger documentation
│ ├── JWT-based authentication
│ ├── PostgreSQL database with jOOQ ORM
│ ├── Flyway database migrations
│ ├── Comprehensive logging with Logback
│ └── JUnit 5 and integration tests
├── Frontend (Vue 3, TypeScript, Vite)
│ ├── Single Page Application (SPA)
│ ├── Responsive UI with CSS Grid/Flexbox
│ ├── Pinia state management
│ ├── Vue Router with auth guards
│ └── Axios HTTP client with JWT interceptors
├── Database (PostgreSQL 16)
│ ├── Schema managed by Flyway migrations
│ ├── jOOQ-generated DTOs and repositories
│ └── Indexed queries for performance
└── Deployment
├── Docker multi-stage build
├── Docker Compose for local dev
└── GitLab CI/CD pipeline
```
## Prerequisites
- **Java 21** or later
- **Node.js 20+** and npm 10+
- **PostgreSQL 16+**
- **Docker & Docker Compose** (for containerized setup)
- **Git**
## Quick Start
### Using Docker Compose (Recommended)
```bash
# Clone the repository
git clone <repository-url>
cd mtg-search
# Start all services (PostgreSQL, Backend, built Frontend)
docker-compose up --build
# Backend will be available at: http://localhost:8080
# Frontend will be served by the backend
```
### Local Development Setup
#### 1. Database Setup
```bash
# Start PostgreSQL (using Docker)
docker run --name mtgsearch-postgres \
-e POSTGRES_DB=mtgsearch \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:17-alpine
# Or use your local PostgreSQL installation
```
#### 2. Backend Setup
```bash
cd backend
# Build the project
../gradlew build
# Run the application
../gradlew bootRun
# Backend starts on http://localhost:8080
# Swagger UI available at: http://localhost:8080/swagger-ui.html
# API Docs available at: http://localhost:8080/v3/api-docs
```
#### 3. Frontend Setup
```bash
cd frontend
# Install dependencies
npm install
# Start development server
npm run dev
# Frontend starts on http://localhost:5173
```
## API Endpoints
### Authentication
- **POST** `/api/v1/auth/register` - Register a new user
```json
{
"username": "john_doe",
"email": "john@example.com",
"password": "SecurePassword123!"
}
```
- **POST** `/api/v1/auth/login` - Login and get JWT token
```json
{
"username": "john_doe",
"password": "SecurePassword123!"
}
```
Returns:
```json
{
"token": "eyJhbGc...",
"id": 1,
"username": "john_doe",
"email": "john@example.com"
}
```
- **GET** `/api/v1/auth/health` - Health check (no auth required)
### Protected Endpoints
All protected endpoints require JWT token in Authorization header:
```
Authorization: Bearer <token>
```
## Building
### Build Backend
```bash
./gradlew clean build
# Build outputs to: backend/build/libs/
```
### Build Frontend
```bash
cd frontend
npm run build
# Build outputs to: backend/src/main/resources/static/
```
### Build Full Docker Image
```bash
docker build -t mtgsearch:latest .
```
## Testing
### Backend Unit Tests
```bash
./gradlew test
```
### Backend Integration Tests
```bash
./gradlew integrationTest
```
### Frontend Tests
```bash
cd frontend
npm run test
npm run test:ui # Opens UI dashboard
```
### All Tests with Coverage
```bash
./gradlew test jacocoTestReport
```
## Configuration
### Environment Variables
Create a `.env` file in the project root:
```env
# Database
DB_URL=jdbc:postgresql://localhost:5432/mtgsearch
DB_USER=postgres
DB_PASSWORD=postgres
# JWT
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRATION=86400
# Frontend
VITE_API_URL=http://localhost:8080
```
### Application Properties
Backend configuration in `backend/src/main/resources/application.yml`:
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mtgsearch
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: validate
app:
jwt:
secret: ${JWT_SECRET:your-secret-key}
expiration: ${JWT_EXPIRATION:86400}
```
## Database Migrations
Migrations are managed by Flyway and located in `backend/src/main/resources/db/migration/`
### Adding a New Migration
1. Create a new file: `V2__Add_new_table.sql`
2. Write SQL migration
3. Run application - Flyway will auto-apply migrations
Example migration:
```sql
-- V2__Add_cards_table.sql
CREATE TABLE IF NOT EXISTS cards (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
## Logging
Logs are configured in `backend/src/main/resources/logback-spring.xml`
- **Log Location**: `logs/mtg-search.log`
- **Error Logs**: `logs/mtg-search-error.log`
- **Max File Size**: 100MB
- **Retention**: 30 days
- **Compression**: Automatic gzip compression
View logs:
```bash
tail -f logs/mtg-search.log
tail -f logs/mtg-search-error.log
```
## Development Workflow
### Code Generation
The project uses OpenAPI Generator and jOOQ for code generation:
```bash
# Generate server stubs from OpenAPI spec
./gradlew openApiGenerate
# Generate jOOQ entities from database schema
./gradlew generateJooqCode
```
### Code Quality
```bash
# Format code
./gradlew spotlessApply
# Run static analysis
./gradlew check
# Frontend linting
cd frontend
npm run lint
```
## Deployment
### Staging Deployment
```bash
git checkout develop
git merge feature-branch
git push origin develop
# GitLab CI will automatically build and deploy to staging
```
### Production Deployment
```bash
git tag v0.2.0
git push origin v0.2.0
# GitLab CI will build, test, and deploy to production
```
### Manual Deployment
```bash
# Build Docker image
docker build -t mtgsearch:v0.1.0 .
docker tag mtgsearch:v0.1.0 your-registry/mtgsearch:v0.1.0
# Push to registry
docker push your-registry/mtgsearch:v0.1.0
# Pull and run on server
docker pull your-registry/mtgsearch:v0.1.0
docker run -d \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/mtgsearch \
-e SPRING_DATASOURCE_USERNAME=postgres \
-e SPRING_DATASOURCE_PASSWORD=secure-password \
-e JWT_SECRET=your-production-secret \
-p 8080:8080 \
--name mtgsearch \
your-registry/mtgsearch:v0.1.0
```
## CI/CD Pipeline
The project includes a comprehensive GitLab CI/CD pipeline (`.gitlab-ci.yml`):
1. **Build Stage**
- Compiles Java backend
- Builds Vue.js frontend
- Caches dependencies
2. **Test Stage**
- Unit tests
- Integration tests with PostgreSQL
- Code quality checks
3. **Docker Stage**
- Builds multi-stage Docker image
- Pushes to registry
4. **Deploy Stage**
- Deploys to staging with manual trigger
- Deploys to production on version tags
## Project Structure
```
mtg-search/
├── backend/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/net/moustos/mtgsearch/
│ │ │ │ ├── MtgSearchApplication.java
│ │ │ │ ├── config/
│ │ │ │ ├── controller/
│ │ │ │ ├── service/
│ │ │ │ ├── repository/
│ │ │ │ ├── model/
│ │ │ │ └── security/
│ │ │ └── resources/
│ │ │ ├── application.yml
│ │ │ ├── logback-spring.xml
│ │ │ └── db/migration/
│ │ └── test/
│ │ └── java/
│ └── build.gradle.kts
├── frontend/
│ ├── src/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── services/
│ │ ├── stores/
│ │ ├── router/
│ │ ├── types/
│ │ ├── main.ts
│ │ └── App.vue
│ ├── public/
│ ├── index.html
│ ├── package.json
│ ├── vite.config.ts
│ └── tsconfig.json
├── gradle/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties
├── gradlew
├── gradlew.bat
├── Dockerfile
├── docker-compose.yml
├── .gitlab-ci.yml
├── .gitignore
└── README.md
```
## Troubleshooting
### Backend won't start
```bash
# Check if port 8080 is already in use
lsof -i :8080
# Check PostgreSQL is running
psql -U postgres -h localhost -d mtgsearch -c "SELECT 1;"
# View logs
tail -f logs/mtg-search.log
```
### Frontend connection issues
- Check backend is running on 8080
- Check browser console for errors
- Verify CORS settings in SecurityConfig.java
### Database migration failures
```bash
# Check migration status
./gradlew flywayInfo
# Repair migrations (use with caution)
./gradlew flywayRepair
# Clean database (WARNING: deletes all data)
./gradlew flywayClean
```
### Docker build fails
```bash
# Clear Docker cache
docker system prune -a
# Rebuild with no cache
docker build --no-cache -t mtgsearch:latest .
```
## Performance Optimization
### Backend
- Connection pooling: HikariCP configured with 10 max connections
- Database indexing on frequently queried columns
- Flyway async migration setup
- Request caching headers
### Frontend
- Code splitting with Vite
- Lazy route loading
- Production build minification
- Gzip compression
### Database
- Query result caching
- Indexed columns for search operations
- Batch operations optimization
## Security
- **Password Hashing**: BCrypt with strength 12
- **JWT Tokens**: Auth0 library with HMAC256
- **CORS**: Configured for development (update for production)
- **HTTPS**: Required for production deployment
- **SQL Injection**: Protected via parameterized queries with jOOQ
- **CSRF**: Disabled for API (JWT-based auth)
## Contributing
1. Create a feature branch: `git checkout -b feature/amazing-feature`
2. Commit changes: `git commit -m 'Add amazing feature'`
3. Push to branch: `git push origin feature/amazing-feature`
4. Open a Pull Request
## License
MIT License - see LICENSE file for details
## Support
For issues and questions:
- Open an issue on GitLab
- Check existing documentation
- Review API Swagger docs at `/swagger-ui.html`
## Roadmap
- [ ] Card search functionality with filters
- [ ] Deck building feature
- [ ] Social features (friends, sharing)
- [ ] Mobile app
- [ ] Advanced analytics
- [ ] Magic Online API integration
- [ ] Performance tuning for large datasets
-225
View File
@@ -1,225 +0,0 @@
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.gradle.jvm.toolchain.JavaLanguageVersion
import java.sql.DriverManager
// Version strings for libraries
val postgresqlVersion: String = "42.7.10"
val flywayVersion: String = "12.4.0"
val jooqVersion: String = "3.21.0"
val logbackVersion: String = "1.5.0"
val springSecurityVersion: String = "6.3.1"
val springdocOpenApiVersion: String = "2.5.0"
val jacksonVersion: String = "2.18.0"
val commonsLang3Version: String = "3.14.0"
val guavaVersion: String = "33.1.0-jre"
val lombokVersion: String = "1.18.30"
val junitVersion: String = "5.10.2"
val mockitoVersion: String = "5.11.0"
val testcontainersVersion: String = "1.20.0"
val h2Version: String = "2.3.232"
val restAssuredVersion: String = "5.4.0"
buildscript {
dependencies {
classpath("org.postgresql:postgresql:42.7.10")
classpath("org.flywaydb:flyway-database-postgresql:12.4.0")
}
}
plugins {
id("org.springframework.boot") version "4.0.+"
id("io.spring.dependency-management") version "1.1.6"
id("org.openapi.generator") version "7.6.0"
id("nu.studer.jooq") version "9.0"
id("org.flywaydb.flyway") version "12.4.0"
id("application")
id("java")
}
group = "net.moustos"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
developmentOnly("org.springframework.boot:spring-boot-devtools")
implementation("org.postgresql:postgresql:$postgresqlVersion")
implementation("org.flywaydb:flyway-core:$flywayVersion")
implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion")
implementation("org.jooq:jooq:$jooqVersion")
implementation("org.jooq:jooq-meta:$jooqVersion")
implementation("org.jooq:jooq-codegen:$jooqVersion")
jooqGenerator("org.postgresql:postgresql:$postgresqlVersion")
jooqGenerator("org.jooq:jooq:$jooqVersion")
jooqGenerator("org.jooq:jooq-meta:$jooqVersion")
jooqGenerator("org.jooq:jooq-codegen:$jooqVersion")
implementation("com.auth0:java-jwt:4.4.0")
implementation("org.springframework.security:spring-security-crypto:$springSecurityVersion")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocOpenApiVersion")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
implementation("ch.qos.logback:logback-core:$logbackVersion")
implementation("ch.qos.logback:logback-classic:$logbackVersion")
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
compileOnly("org.projectlombok:lombok:$lombokVersion")
annotationProcessor("org.projectlombok:lombok:$lombokVersion")
implementation("org.apache.commons:commons-lang3:$commonsLang3Version")
implementation("com.google.guava:guava:$guavaVersion")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:testcontainers:$testcontainersVersion")
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
testImplementation("com.h2database:h2:$h2Version")
testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion")
testImplementation("org.mockito:mockito-core:$mockitoVersion")
testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion")
testImplementation("io.rest-assured:rest-assured:$restAssuredVersion")
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
application {
mainClass.set("net.moustos.mtgsearch.MtgSearchApplication")
}
springBoot {
buildInfo()
}
openApiGenerate {
generatorName.set("spring")
inputSpec.set("$projectDir/openapi/api.yaml")
outputDir.set("$projectDir/build/generated")
apiPackage.set("net.moustos.mtgsearch.api")
modelPackage.set("net.moustos.mtgsearch.model.api")
globalProperties.set(mapOf(
"apis" to "true",
"models" to "true"
))
configOptions.set(mapOf(
"delegatePattern" to "true",
"title" to "MTG Search API",
"interfaceOnly" to "false",
"skipDefaultInterface" to "false",
"useSpringBoot3" to "true",
"useJakartaEe" to "true"
))
}
flyway {
url = project.property("db.url") as String
user = project.property("db.user") as String
password = project.property("db.password") as String
}
jooq {
version.set("$jooqVersion")
configurations {
create("main") {
jooqConfiguration.apply {
jdbc.apply {
driver = "org.postgresql.Driver"
url = project.property("db.url") as String
user = project.property("db.user") as String
password = project.property("db.password") as String
}
generator.apply {
name = "org.jooq.codegen.JavaGenerator"
database.apply {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
}
target.apply {
packageName = "net.moustos.mtgsearch.jooq.generated"
directory = "$projectDir/build/generated/jooq"
}
}
}
}
}
}
fun isPostgresAvailable(): Boolean {
val dbUrl = project.property("db.url") as String
val dbUser = project.property("db.user") as String
logger.lifecycle("Checking PostgreSQL availability at $dbUrl with user $dbUser")
return try {
// Load the PostgreSQL driver
Class.forName("org.postgresql.Driver")
DriverManager.setLoginTimeout(10)
DriverManager.getConnection(dbUrl, dbUser, project.property("db.password") as String).use { connection ->
logger.lifecycle("Successfully connected to PostgreSQL database.")
true
}
} catch (ex: Exception) {
logger.error("Failed to connect to PostgreSQL at $dbUrl with user $dbUser: ${ex.message}")
logger.error("Exception details: ", ex)
false
}
}
tasks.named("generateJooq") {
dependsOn("flywayMigrate")
onlyIf {
isPostgresAvailable()
}
doFirst {
logger.lifecycle("Starting jOOQ code generation...")
}
doLast {
logger.lifecycle("Finished jOOQ task.")
}
}
tasks.named("flywayMigrate") {
onlyIf {
isPostgresAvailable()
}
}
tasks.named("compileJava") {
dependsOn("openApiGenerate")
}
sourceSets["main"].java {
srcDir("$buildDir/generated/src/main/java")
srcDir("$buildDir/generated/jooq")
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStandardStreams = false
}
}
tasks.named("build") {
dependsOn("openApiGenerate")
}
-162
View File
@@ -1,162 +0,0 @@
openapi: 3.0.0
info:
title: MTG Search API
version: 0.1.0
description: Magic The Gathering Card Search REST API
contact:
name: API Support
url: https://github.com/example/mtg-search
license:
name: MIT
servers:
- url: http://localhost:8080
description: Development server
- url: https://api.mtgsearch.example.com
description: Production server
paths:
/api/v1/auth/register:
post:
tags:
- Authentication
summary: Register a new user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'201':
description: User registered successfully
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
description: Invalid input
'409':
description: User already exists
/api/v1/auth/login:
post:
tags:
- Authentication
summary: Login user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Login successful
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'401':
description: Invalid credentials
'500':
description: Server error
/api/v1/auth/health:
get:
tags:
- Health
summary: Health check
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
components:
schemas:
RegisterRequest:
type: object
required:
- username
- email
- password
properties:
username:
type: string
minLength: 3
maxLength: 100
example: john_doe
email:
type: string
format: email
example: john@example.com
password:
type: string
format: password
minLength: 8
example: SecurePassword123!
LoginRequest:
type: object
required:
- username
- password
properties:
username:
type: string
example: john_doe
password:
type: string
format: password
example: SecurePassword123!
UserResponse:
type: object
properties:
id:
type: integer
format: int64
username:
type: string
email:
type: string
message:
type: string
error:
type: string
LoginResponse:
type: object
properties:
token:
type: string
description: JWT token for authentication
id:
type: integer
format: int64
username:
type: string
email:
type: string
error:
type: string
HealthResponse:
type: object
properties:
status:
type: string
enum:
- ok
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
@@ -1,17 +0,0 @@
package net.moustos.mtgsearch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
/**
* Main Spring Boot Application entry point for MTG Search
*/
@SpringBootApplication
@ComponentScan(basePackages = {"net.moustos.mtgsearch"})
public class MtgSearchApplication {
public static void main(String[] args) {
SpringApplication.run(MtgSearchApplication.class, args);
}
}
@@ -1,37 +0,0 @@
package net.moustos.mtgsearch.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* General application configuration
*/
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:5173"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -1,69 +0,0 @@
package net.moustos.mtgsearch.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import net.moustos.mtgsearch.security.JwtAuthenticationFilter;
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
/**
* Spring Security configuration
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(UserDetailsServiceImpl userDetailsService, PasswordEncoder passwordEncoder,
JwtAuthenticationFilter jwtAuthenticationFilter) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder);
return authenticationManagerBuilder.build();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.disable())
.csrf(csrf -> csrf.disable())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\"}");
})
)
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/swagger-ui/**", "/javadoc/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/health", "/health/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@@ -1,22 +0,0 @@
package net.moustos.mtgsearch.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC configuration for serving static resources
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(org.springframework.http.CacheControl.maxAge(365, java.util.concurrent.TimeUnit.DAYS));
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/index.html");
}
}
@@ -1,130 +0,0 @@
package net.moustos.mtgsearch.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import net.moustos.mtgsearch.model.User;
import net.moustos.mtgsearch.service.AuthService;
import java.util.Map;
/**
* Authentication controller for login and registration endpoints
*/
@RestController
@RequestMapping("/api/v1/auth")
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"})
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
/**
* Register a new user
*/
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
try {
User user = authService.register(request.getUsername(), request.getEmail(), request.getPassword());
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"id", user.getId(),
"username", user.getUsername(),
"email", user.getEmail(),
"message", "User registered successfully"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
"error", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"error", "Registration failed"
));
}
}
/**
* Login user and return JWT token
*/
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
String token = authService.authenticate(request.getUsername(), request.getPassword());
User user = authService.getUserByUsername(request.getUsername()).orElseThrow();
return ResponseEntity.ok(Map.of(
"token", token,
"id", user.getId(),
"username", user.getUsername(),
"email", user.getEmail()
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
"error", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"error", "Login failed"
));
}
}
/**
* Health check endpoint
*/
@GetMapping("/health")
public ResponseEntity<Map<String, String>> health() {
return ResponseEntity.ok(Map.of("status", "ok"));
}
// DTOs
public static class RegisterRequest {
public String username;
public String email;
public String password;
public RegisterRequest() {
}
public RegisterRequest(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
}
public static class LoginRequest {
public String username;
public String password;
public LoginRequest() {
}
public LoginRequest(String username, String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
}
}
@@ -1,55 +0,0 @@
package net.moustos.mtgsearch.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* User entity for authentication
*/
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String username;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 255)
private String password;
@Column(nullable = false)
private Boolean active;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
active = true;
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -1,18 +0,0 @@
package net.moustos.mtgsearch.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import net.moustos.mtgsearch.model.User;
import java.util.Optional;
/**
* User repository for database operations
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
@@ -1,57 +0,0 @@
package net.moustos.mtgsearch.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
import java.io.IOException;
/**
* JWT Authentication filter to validate tokens on each request
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsServiceImpl userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = extractJwtFromRequest(request);
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
String username = jwtTokenProvider.validateTokenAndGetUsername(jwt);
var userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String extractJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -1,83 +0,0 @@
package net.moustos.mtgsearch.security;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
/**
* JWT Token provider for generating and validating JWT tokens
*/
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret:your-secret-key-change-in-production}")
private String jwtSecret;
@Value("${app.jwt.expiration:86400}")
private long jwtExpiration;
private static final String CLAIM_USER_ID = "userId";
private static final String CLAIM_USERNAME = "username";
private static final String CLAIM_EMAIL = "email";
/**
* Generate a JWT token for a user
*/
public String generateToken(long userId, String username, String email) {
Instant now = Instant.now();
Instant expiresAt = now.plus(jwtExpiration, ChronoUnit.SECONDS);
return JWT.create()
.withClaim(CLAIM_USER_ID, userId)
.withClaim(CLAIM_USERNAME, username)
.withClaim(CLAIM_EMAIL, email)
.withIssuedAt(now)
.withExpiresAt(expiresAt)
.withIssuer("mtg-search")
.sign(Algorithm.HMAC256(jwtSecret));
}
/**
* Validate JWT token and extract user ID
*/
public long validateTokenAndGetUserId(String token) throws JWTVerificationException {
return JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer("mtg-search")
.build()
.verify(token)
.getClaim(CLAIM_USER_ID)
.asLong();
}
/**
* Validate JWT token and extract username
*/
public String validateTokenAndGetUsername(String token) throws JWTVerificationException {
return JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer("mtg-search")
.build()
.verify(token)
.getClaim(CLAIM_USERNAME)
.asString();
}
/**
* Validate JWT token
*/
public boolean validateToken(String token) {
try {
JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer("mtg-search")
.build()
.verify(token);
return true;
} catch (JWTVerificationException e) {
return false;
}
}
}
@@ -1,86 +0,0 @@
package net.moustos.mtgsearch.service;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import net.moustos.mtgsearch.model.User;
import net.moustos.mtgsearch.repository.UserRepository;
import net.moustos.mtgsearch.security.JwtTokenProvider;
import java.util.Optional;
/**
* Authentication service for user login and registration
*/
@Service
@Transactional
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider;
}
/**
* Register a new user
*/
public User register(String username, String email, String password) {
if (userRepository.existsByUsername(username)) {
throw new IllegalArgumentException("Username already exists");
}
if (userRepository.existsByEmail(email)) {
throw new IllegalArgumentException("Email already exists");
}
User user = User.builder()
.username(username)
.email(email)
.password(passwordEncoder.encode(password))
.active(true)
.build();
return userRepository.save(user);
}
/**
* Authenticate user and return JWT token
*/
public String authenticate(String username, String password) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
throw new IllegalArgumentException("Invalid username or password");
}
User user = userOpt.get();
if (!user.getActive()) {
throw new IllegalArgumentException("User account is inactive");
}
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new IllegalArgumentException("Invalid username or password");
}
return jwtTokenProvider.generateToken(user.getId(), user.getUsername(), user.getEmail());
}
/**
* Get user by ID
*/
@Transactional(readOnly = true)
public Optional<User> getUserById(Long userId) {
return userRepository.findById(userId);
}
/**
* Get user by username
*/
@Transactional(readOnly = true)
public Optional<User> getUserByUsername(String username) {
return userRepository.findByUsername(username);
}
}
@@ -1,42 +0,0 @@
package net.moustos.mtgsearch.service;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import net.moustos.mtgsearch.repository.UserRepository;
import java.util.Collections;
/**
* User details service for Spring Security
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
boolean active = Boolean.TRUE.equals(user.getActive());
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")))
.accountLocked(!active)
.accountExpired(false)
.credentialsExpired(false)
.disabled(!active)
.build();
}
}
@@ -1,72 +0,0 @@
spring:
application:
name: ${app.name:mtg-search}
version: ${app.version:0.1.0}
datasource:
url: ${db.url:jdbc:postgresql://localhost:5432/mtgsearch}
username: ${db.user:postgres}
password: ${db.password:postgres}
hikari:
maximum-pool-size: ${db.maxPoolSize:10}
minimum-idle: 2
idle-timeout: 300000
max-lifetime: 1200000
auto-commit: true
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate.format_sql: true
hibernate.jdbc.batch_size: 20
hibernate.order_inserts: true
hibernate.order_updates: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
sql-migration-prefix: V
sql-migration-suffix: .sql
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
server:
port: 8080
servlet:
context-path: /
compression:
enabled: true
min-response-size: 1024
app:
jwt:
secret: ${jwt.secret:your-secret-key-change-in-production}
expiration: ${jwt.expiration:86400}
logging:
level:
root: ${log.level:INFO}
net.moustos.mtgsearch: DEBUG
org.springframework.web: INFO
org.springframework.security: DEBUG
config: classpath:logback-spring.xml
file:
name: ${log.dir:logs}/app.log
max-size: 100MB
max-history: 30
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
enabled: true
operations-sorter: method
tags-sorter: alpha
@@ -1,16 +0,0 @@
-- Initial schema setup for MTG Search
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for common queries
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(active);
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_DIR" value="logs"/>
<property name="LOG_FILE_NAME" value="mtg-search"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- Console appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- Rolling file appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/${LOG_FILE_NAME}.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Error file appender -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/${LOG_FILE_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Root logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<!-- Application loggers -->
<logger name="net.moustos.mtgsearch" level="DEBUG"/>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.security" level="DEBUG"/>
<logger name="org.hibernate" level="WARN"/>
<logger name="org.flywaydb" level="INFO"/>
</configuration>
@@ -1,124 +0,0 @@
package net.moustos.mtgsearch;
import org.junit.jupiter.api.BeforeEach;
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.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import net.moustos.mtgsearch.repository.UserRepository;
/**
* Integration tests for authentication API
*/
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(locations = "classpath:application-test.yml")
public class AuthControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
public void setUp() {
// Clear database before each test
userRepository.deleteAll();
}
@Test
public void testUserRegistration() throws Exception {
String registerPayload = """
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("testuser"))
.andExpect(jsonPath("$.email").value("test@example.com"));
}
@Test
public void testUserLogin() throws Exception {
// First register
String registerPayload = """
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isCreated());
// Then login
String loginPayload = """
{
"username": "testuser",
"password": "SecurePassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").exists())
.andExpect(jsonPath("$.username").value("testuser"));
}
@Test
public void testLoginWithInvalidCredentials() throws Exception {
String loginPayload = """
{
"username": "nonexistent",
"password": "WrongPassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginPayload))
.andExpect(status().isUnauthorized());
}
@Test
public void testDuplicateUsernameRegistration() throws Exception {
String registerPayload = """
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}
""";
// Register first user
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isCreated());
// Try to register with same username
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isBadRequest());
}
}
@@ -1,128 +0,0 @@
package net.moustos.mtgsearch.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import net.moustos.mtgsearch.model.User;
import net.moustos.mtgsearch.repository.UserRepository;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Disabled;
/**
* Unit tests for AuthService
*/
@SpringBootTest
@Testcontainers
@Disabled("Requires Docker for Testcontainers")
@DisplayName("AuthService Tests")
public class AuthServiceTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("mtgsearch_test")
.withUsername("postgres")
.withPassword("postgres");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private AuthService authService;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
public void setUp() {
userRepository.deleteAll();
}
@Test
@DisplayName("Should register a new user successfully")
public void testRegisterUser() {
User user = authService.register("testuser", "test@example.com", "SecurePassword123!");
assertNotNull(user.getId());
assertEquals("testuser", user.getUsername());
assertEquals("test@example.com", user.getEmail());
assertTrue(user.getActive());
}
@Test
@DisplayName("Should throw exception for duplicate username")
public void testRegisterDuplicateUsername() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
assertThrows(IllegalArgumentException.class, () ->
authService.register("testuser", "another@example.com", "SecurePassword123!")
);
}
@Test
@DisplayName("Should throw exception for duplicate email")
public void testRegisterDuplicateEmail() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
assertThrows(IllegalArgumentException.class, () ->
authService.register("anotheruser", "test@example.com", "SecurePassword123!")
);
}
@Test
@DisplayName("Should authenticate user successfully")
public void testAuthenticateUser() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
String token = authService.authenticate("testuser", "SecurePassword123!");
assertNotNull(token);
assertFalse(token.isEmpty());
}
@Test
@DisplayName("Should throw exception for invalid username")
public void testAuthenticateWithInvalidUsername() {
assertThrows(IllegalArgumentException.class, () ->
authService.authenticate("nonexistent", "SomePassword123!")
);
}
@Test
@DisplayName("Should throw exception for invalid password")
public void testAuthenticateWithInvalidPassword() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
assertThrows(IllegalArgumentException.class, () ->
authService.authenticate("testuser", "WrongPassword123!")
);
}
@Test
@DisplayName("Should retrieve user by ID")
public void testGetUserById() {
User registeredUser = authService.register("testuser", "test@example.com", "SecurePassword123!");
var retrievedUser = authService.getUserById(registeredUser.getId());
assertTrue(retrievedUser.isPresent());
assertEquals(registeredUser.getId(), retrievedUser.get().getId());
}
@Test
@DisplayName("Should retrieve user by username")
public void testGetUserByUsername() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
var retrievedUser = authService.getUserByUsername("testuser");
assertTrue(retrievedUser.isPresent());
assertEquals("testuser", retrievedUser.get().getUsername());
}
}
@@ -1,108 +0,0 @@
package net.moustos.mtgsearch.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import net.moustos.mtgsearch.model.User;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Disabled;
/**
* Tests for UserRepository
*/
@SpringBootTest
@Testcontainers
@Disabled("Requires Docker for Testcontainers")
public class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("mtgsearch_test")
.withUsername("postgres")
.withPassword("postgres");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@BeforeEach
public void setUp() {
userRepository.deleteAll();
}
@Test
public void testFindByUsername() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
var found = userRepository.findByUsername("testuser");
assertTrue(found.isPresent());
assertEquals("testuser", found.get().getUsername());
}
@Test
public void testFindByEmail() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
var found = userRepository.findByEmail("test@example.com");
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
@Test
public void testExistsByUsername() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
assertTrue(userRepository.existsByUsername("testuser"));
assertFalse(userRepository.existsByUsername("nonexistent"));
}
@Test
public void testExistsByEmail() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
assertTrue(userRepository.existsByEmail("test@example.com"));
assertFalse(userRepository.existsByEmail("nonexistent@example.com"));
}
}
@@ -1,22 +0,0 @@
spring:
profiles:
active: test
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
flyway:
enabled: false
app:
jwt:
secret: test-secret-key
expiration: 3600
logging:
level:
root: WARN
net.moustos.mtgsearch: DEBUG
+43
View File
@@ -0,0 +1,43 @@
plugins {
id 'MTGDBPlugin'
id 'java'
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.vaadin' version '24.9.3'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'Demo project for Spring Boot'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
ext {
set('vaadinVersion', "24.9.3")
}
dependencies {
implementation 'com.vaadin:vaadin-spring-boot-starter'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "com.vaadin:vaadin-bom:${vaadinVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
-199
View File
@@ -1,199 +0,0 @@
import org.gradle.api.tasks.testing.logging.TestLogEvent
import java.util.Properties
import java.io.File
plugins {
id("java")
id("org.springframework.boot") version "4.0.+" apply false
id("io.spring.dependency-management") version "1.1.6"
id("org.sonarqube") version "7.2.3.7755"
}
sonar {
properties {
property("sonar.projectKey", "MTGSearch")
property("sonar.projectName", "MTGSearch")
property("sonar.qualitygate.wait", true)
}
}
group = "net.moustos"
version = "0.1.0"
// Load dev.properties if it exists
val devPropsFile = File("dev.properties")
val devProps = Properties().apply {
if (devPropsFile.exists()) {
load(devPropsFile.inputStream())
}
}
// Define properties with defaults, preferring env vars over dev.properties
val propertiesMap = mapOf(
"db.url" to (System.getenv("DB_URL") ?: devProps.getProperty("db.url", "jdbc:postgresql://localhost:5432/mtgsearch")),
"db.user" to (System.getenv("DB_USER") ?: devProps.getProperty("db.user", "postgres")),
"db.password" to (System.getenv("DB_PASSWORD") ?: devProps.getProperty("db.password", "postgres")),
"db.maxPoolSize" to (System.getenv("DB_MAX_POOL_SIZE") ?: devProps.getProperty("db.maxPoolSize", "10")),
"jwt.secret" to (System.getenv("JWT_SECRET") ?: devProps.getProperty("jwt.secret", "your-secret-key-change-in-production")),
"jwt.expiration" to (System.getenv("JWT_EXPIRATION") ?: devProps.getProperty("jwt.expiration", "86400")),
"app.name" to (System.getenv("APP_NAME") ?: devProps.getProperty("app.name", "mtg-search")),
"app.version" to (System.getenv("APP_VERSION") ?: devProps.getProperty("app.version", "0.1.0")),
"app.environment" to (System.getenv("APP_ENVIRONMENT") ?: devProps.getProperty("app.environment", "development")),
"server.port" to (System.getenv("SERVER_PORT") ?: devProps.getProperty("server.port", "8080")),
"server.servlet.contextPath" to (System.getenv("SERVER_SERVLET_CONTEXT_PATH") ?: devProps.getProperty("server.servlet.contextPath", "/")),
"vite.apiUrl" to (System.getenv("VITE_API_URL") ?: devProps.getProperty("vite.apiUrl", "http://localhost:8080")),
"vite.appTitle" to (System.getenv("VITE_APP_TITLE") ?: devProps.getProperty("vite.appTitle", "MTG Search")),
"log.level" to (System.getenv("LOG_LEVEL") ?: devProps.getProperty("log.level", "DEBUG")),
"log.dir" to (System.getenv("LOG_DIR") ?: devProps.getProperty("log.dir", "logs"))
)
// Set system properties for Spring Boot
propertiesMap.forEach { (key, value) ->
System.setProperty(key, value)
}
// Also set as project extra for Gradle use
propertiesMap.forEach { (key, value) ->
extra.set(key, value)
}
allprojects {
repositories {
mavenCentral()
maven {
url = uri("https://repo.maven.apache.org/maven2/")
}
}
}
configure(subprojects.filter { it.name == "backend" }) {
apply(plugin = "java")
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStandardStreams = false
}
}
}
// Developer helper tasks
tasks.register("createDevProperties") {
group = "developer"
description = "Creates dev.properties file with default values if it doesn't exist"
doLast {
val devPropsFile = File("dev.properties")
if (!devPropsFile.exists()) {
val defaultContent = """
# Development properties
# This file contains configuration values for build and runtime.
# Do not commit this file to the repository.
# Database Configuration
db.url=jdbc:postgresql://localhost:5432/mtgsearch
db.user=postgres
db.password=postgres
db.maxPoolSize=10
# JWT Configuration
jwt.secret=your-secret-key-change-in-production
jwt.expiration=86400
# Application Configuration
app.name=mtg-search
app.version=0.1.0
app.environment=development
# Server Configuration
server.port=8080
server.servlet.contextPath=/
# Frontend Configuration
vite.apiUrl=http://localhost:8080
vite.appTitle=MTG Search
# Logging
log.level=DEBUG
log.dir=logs
""".trimIndent()
devPropsFile.writeText(defaultContent)
println("Created dev.properties with default values")
} else {
println("dev.properties already exists")
}
}
}
tasks.register<Exec>("startPostgres") {
group = "developer"
description = "Starts the PostgreSQL container using docker-compose"
doFirst {
// Check if Docker is available
val dockerCheck = providers.exec {
commandLine("docker", "info")
isIgnoreExitValue = true
}
if (dockerCheck.result.get().exitValue != 0) {
throw GradleException("""
Docker is not available or not running. Please ensure:
1. Docker daemon is running
2. You have permission to access Docker (try 'sudo usermod -aG docker ${'$'}USER' and restart your session)
3. Docker Compose is installed
""".trimIndent())
}
println("Starting PostgreSQL container...")
}
commandLine("docker", "compose", "up", "-d", "postgres")
workingDir = project.rootDir
doLast {
println("PostgreSQL container started. Waiting for it to be healthy...")
// Optional: wait for health check
Thread.sleep(5000) // Simple wait, could be improved
println("PostgreSQL should be ready now.")
}
}
tasks.register<Exec>("stopPostgres") {
group = "developer"
description = "Stops the PostgreSQL container using docker-compose"
doFirst {
// Check if Docker is available
val dockerCheck = providers.exec {
commandLine("docker", "info")
isIgnoreExitValue = true
}
if (dockerCheck.result.get().exitValue != 0) {
throw GradleException("""
Docker is not available or not running. Please ensure:
1. Docker daemon is running
2. You have permission to access Docker (try 'sudo usermod -aG docker ${'$'}USER' and restart your session)
3. Docker Compose is installed
""".trimIndent())
}
println("Stopping PostgreSQL container...")
}
commandLine("docker", "compose", "down")
workingDir = project.rootDir
doLast {
println("PostgreSQL container stopped.")
}
}
+13
View File
@@ -0,0 +1,13 @@
plugins {
id 'groovy-base'
id 'java-library'
}
repositories {
mavenCentral()
}
dependencies {
implementation gradleApi()
implementation localGroovy()
}
+14
View File
@@ -0,0 +1,14 @@
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.Task;
public class MTGDBPlugin implements Plugin<Project> {
@Override
public void apply(Project target) {
Task javaTask = target.task("javaTask");
}
}
@@ -0,0 +1 @@
implementation-class=MTGDBPlugin
+9
View File
@@ -0,0 +1,9 @@
services:
postgres:
image: 'postgres:latest'
environment:
- 'POSTGRES_DB=mtg'
- 'POSTGRES_PASSWORD=password'
- 'POSTGRES_USER=username'
ports:
- '5432:5432'
+155
View File
@@ -0,0 +1,155 @@
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
plugins {
id 'java'
id 'com.google.cloud.tools.jib' version '3.4.2'
id 'application'
}
application {
mainClass = 'com.example.DbSetup'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.postgresql:postgresql:42.7.3'
}
def containername = "mtg-search-db"
def dbName = "mtgsearch"
def dbUser = "dbuser"
def dbPass = "dbpass"
def psqlZipFile = new File("$rootDir\\AllPrintings.psql.zip")
def psqlFile = new File("$rootDir\\AllPrintings\\AllPrintings.psql")
jib {
from {
image = 'postgres:17' // base image
}
to {
image = "hub.docker.com/$containername:latest"
auth {
username = System.getenv("DOCKER_USER")?:""
password = System.getenv("DOCKER_PASS")?:""
}
}
container {
// Jib runs as rootless by default; PostgreSQL needs a few tweaks
entrypoint = ['docker-entrypoint.sh']
args = ['postgres']
environment = [
POSTGRES_USER: dbUser,
POSTGRES_PASSWORD: dbPass,
POSTGRES_DB: dbName
]
ports = ['5432']
}
}
// Custom Gradle task to run database setup logic
tasks.register('runDbSetup', JavaExec) {
dependsOn classes
mainClass.set('com.example.DbSetup')
classpath = sourceSets.main.runtimeClasspath
environment 'POSTGRES_USER', dbUser
environment 'POSTGRES_PASSWORD', dbPass
environment 'POSTGRES_DB', dbName
environment 'PSQL_FILE', psqlFile.canonicalPath
}
// Ensure setup runs before building the Docker image
tasks.named('jib') {
dependsOn('runDbSetup')
}
tasks.register('startPostgresContainer', Exec) {
commandLine 'docker', 'run', '-d',
'--name', "$containername",
'-e', "POSTGRES_USER=$dbUser",
'-e', "POSTGRES_PASSWORD=$dbPass",
'-e', "POSTGRES_DB=$dbName",
'-p', '5432:5432',
'postgres:17'
}
tasks.register('stopPostgresContainer', Exec) {
commandLine 'docker', 'rm', '-f', containername
}
// Hook the lifecycle
tasks.named('runDbSetup') {
dependsOn('startPostgresContainer')
finalizedBy('stopPostgresContainer')
}
tasks.register('getLatestMTGJson') {
outputs.files files("AllPrintings.psql.zip.sha256","AllPrintings.psql.zip")
outputs.upToDateWhen {
def url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip.sha256")
def urlHash = url.text // Groovy shortcut to read text from URL
def file = new File('AllPrintings.psql.zip.sha256')
if(file.exists()) {
def fileHash = file.text
def equal = urlHash == fileHash
if(!equal){
println("AllPrintings.psql.zip.sha256 out of date")
}
return equal
}
println("AllPrintings.psql.zip.sha256 not found")
return false
}
doLast {
def url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip")
println("Downloading $url to $psqlZipFile")
psqlZipFile.withOutputStream { out ->
out << url.openStream()
}
url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip.sha256")
def file = new File("AllPrintings.psql.zip.sha256")
println("Downloading $url to $file")
file.withOutputStream { out ->
out << url.openStream()
}
}
}
tasks.register("unzipAllPrintings"){
dependsOn("getLatestMTGJson")
doLast {
def destinationDir = new File("AllPrintings")
// Ensure the destination directory exists
destinationDir.mkdirs()
psqlZipFile.withInputStream { fis ->
new ZipInputStream(fis).with { zis ->
ZipEntry entry
while ((entry = zis.nextEntry) != null) {
def outputFile = new File(destinationDir, entry.name)
if (entry.isDirectory()) {
outputFile.mkdirs()
} else {
outputFile.parentFile.mkdirs() // Ensure parent directories exist
outputFile.withOutputStream { fos ->
fos << zis // Copy the entry's content
}
}
zis.closeEntry()
}
}
}
println "Successfully unzipped ${psqlZipFile.name} to ${destinationDir.absolutePath}"
}
}
+2
View File
@@ -0,0 +1,2 @@
FROM postgres:17
COPY ./init.sql /docker-entrypoint-initdb.d/
+67
View File
@@ -0,0 +1,67 @@
package com.example;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.Objects;
import java.util.StringJoiner;
public class DbSetup {
public static void main(String[] args) throws Exception {
// ========================
//todo replace with better way to wait for container to be ready
Thread.sleep(5000);
// ========================
String dbuser = Objects.requireNonNullElse(System.getenv("POSTGRES_USER"),"dbuser");
String dbpass = Objects.requireNonNullElse(System.getenv("POSTGRES_PASSWORD"),"dbpass");
String dbname = Objects.requireNonNullElse(System.getenv("POSTGRES_DB"),"mtgsearch");
String psqlFilePath = Objects.requireNonNullElse(System.getenv("PSQL_FILE"),"AllPrintings/AllPrintings.psql");
String url = "jdbc:postgresql://localhost:5432/" + dbname;
//Load the MTGJson Allprintings PSQL File
File psqlFile = new File(psqlFilePath);
String[] commands = {"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe","-U",dbuser,"-d",dbname,"-h","localhost","-p","5432","-f",psqlFile.getCanonicalPath()};
String[] envVars = { "PGPASSWORD="+dbpass };
Runtime rt = Runtime.getRuntime();
System.out.println(String.join(" ",commands));
Process proc = rt.exec(commands,envVars);
System.out.println("<OUTPUT>");
try (BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("Output: " + line);
}
}
System.out.println("</OUTPUT>");
System.out.println("<ERROR>");
try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(proc.getErrorStream()))) {
String line;
while ((line = errorReader.readLine()) != null) {
System.err.println("Error: " + line);
}
}
System.out.println("</ERROR>");
int exitVal = proc.waitFor();
System.out.println("Process exitValue: " + exitVal);
// try (Connection conn = DriverManager.getConnection(url, dbuser, dbpass);
// Statement stmt = conn.createStatement()) {
// stmt.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)");
// stmt.execute("INSERT INTO users (name) VALUES ('Alice'), ('Bob')");
// System.out.println("✅ Database setup complete!");
// }
}
}
-51
View File
@@ -1,51 +0,0 @@
services:
postgres:
image: postgres:17-alpine
container_name: mtgsearch-db
environment:
POSTGRES_DB: mtgsearch
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile
container_name: mtgsearch-backend
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mtgsearch
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: postgres
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production}
JWT_EXPIRATION: 86400
ports:
- "8080:8080"
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/auth/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
postgres_data:
driver: local
networks:
default:
name: mtgsearch-network
-34
View File
@@ -1,34 +0,0 @@
# Frontend ESLint configuration
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"vue",
"@typescript-eslint"
],
"rules": {
"vue/multi-word-component-names": "off",
"vue/no-setup-props-destructure": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "warn"
}
}
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
.env.local
.DS_Store
*.log
npm-debug.log*
-3
View File
@@ -1,3 +0,0 @@
frontend/.eslintrc.json
frontend/.prettierrc
frontend/.gitignore
-11
View File
@@ -1,11 +0,0 @@
; Frontend Prettier configuration
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"vueIndentScriptAndStyle": true
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MTG Search</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
{
"name": "mtg-search-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.2.0",
"axios": "^1.7.7",
"pinia-plugin-persistedstate": "^3.2.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.3.1",
"typescript": "^5.4.5",
"vue-tsc": "^1.8.27",
"@vue/test-utils": "^2.4.6",
"vitest": "^2.0.0",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.26.0",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0"
}
}
-2
View File
@@ -1,2 +0,0 @@
*
!.gitkeep
-131
View File
@@ -1,131 +0,0 @@
<template>
<div id="app" class="app">
<header class="navbar">
<div class="navbar-container">
<router-link to="/" class="navbar-brand">
<h1>MTG Search</h1>
</router-link>
<nav class="navbar-menu">
<router-link v-if="!authStore.isAuthenticated" to="/login" class="nav-link">Login</router-link>
<router-link v-if="!authStore.isAuthenticated" to="/register" class="nav-link">Register</router-link>
<router-link v-if="authStore.isAuthenticated" to="/dashboard" class="nav-link">Dashboard</router-link>
<button v-if="authStore.isAuthenticated" @click="logout" class="nav-link logout-btn">Logout</button>
</nav>
</div>
</header>
<main class="main-content">
<router-view />
</main>
<footer class="footer">
<p>&copy; 2024 MTG Search. All rights reserved.</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from './stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
const logout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
background-color: #1a1a2e;
color: white;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
text-decoration: none;
color: white;
}
.navbar-brand h1 {
font-size: 1.5rem;
font-weight: 600;
}
.navbar-menu {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s ease;
cursor: pointer;
border: none;
background: transparent;
font-size: 1rem;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.logout-btn {
background-color: #e74c3c;
}
.logout-btn:hover {
background-color: #c0392b;
}
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
width: 100%;
padding: 2rem 1rem;
}
.footer {
background-color: #1a1a2e;
color: white;
text-align: center;
padding: 2rem;
margin-top: 2rem;
}
</style>
-15
View File
@@ -1,15 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import createPersistedState from 'pinia-plugin-persistedstate'
const app = createApp(App)
const pinia = createPinia()
pinia.use(createPersistedState())
app.use(pinia)
app.use(router)
app.mount('#app')
-135
View File
@@ -1,135 +0,0 @@
<template>
<div class="dashboard-page">
<div class="dashboard-container">
<h1>Welcome, {{ authStore.user?.username }}!</h1>
<div class="dashboard-grid">
<div class="card">
<h2>Profile Information</h2>
<div class="profile-info">
<p><strong>Username:</strong> {{ authStore.user?.username }}</p>
<p><strong>Email:</strong> {{ authStore.user?.email }}</p>
<p><strong>ID:</strong> {{ authStore.user?.id }}</p>
</div>
</div>
<div class="card">
<h2>Quick Actions</h2>
<div class="actions">
<button class="btn btn-secondary">Search Cards</button>
<button class="btn btn-secondary">View Decks</button>
<button class="btn btn-secondary">Settings</button>
</div>
</div>
</div>
<div class="card">
<h2>Search Cards</h2>
<p style="color: #666; margin-bottom: 1rem;">Coming soon...</p>
<input
type="text"
placeholder="Search Magic The Gathering cards..."
class="search-input"
disabled
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.dashboard-page {
display: flex;
flex-direction: column;
gap: 2rem;
}
.dashboard-container h1 {
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card h2 {
color: #1a1a2e;
margin-bottom: 1.5rem;
font-size: 1.4rem;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-info p {
color: #333;
line-height: 1.6;
}
.actions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
}
.btn-secondary {
background-color: #e74c3c;
color: white;
}
.btn-secondary:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.search-input {
width: 100%;
padding: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: #1a1a2e;
}
.search-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
</style>
-96
View File
@@ -1,96 +0,0 @@
<template>
<div class="home-page">
<div class="hero">
<h1>Welcome to MTG Search</h1>
<p>Search and explore Magic The Gathering cards</p>
<div class="hero-buttons" v-if="!authStore.isAuthenticated">
<router-link to="/login" class="btn btn-primary">Login</router-link>
<router-link to="/register" class="btn btn-secondary">Register</router-link>
</div>
<div class="hero-buttons" v-else>
<router-link to="/dashboard" class="btn btn-primary">Go to Dashboard</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.home-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.hero {
text-align: center;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero h1 {
font-size: 3rem;
color: #1a1a2e;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.2rem;
color: #666;
margin-bottom: 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-secondary {
background-color: #e74c3c;
color: white;
}
.btn-secondary:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
-181
View File
@@ -1,181 +0,0 @@
<template>
<div class="login-page">
<div class="login-container">
<h1>Login to MTG Search</h1>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
v-model="form.username"
id="username"
type="text"
placeholder="Enter your username"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="form.password"
id="password"
type="password"
placeholder="Enter your password"
required
/>
</div>
<div v-if="error" class="alert alert-error">
{{ error }}
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<p class="signup-link">
Don't have an account? <router-link to="/register">Register here</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
username: '',
password: '',
})
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
loading.value = true
error.value = ''
try {
await authStore.login(form.username, form.password)
router.push('/dashboard')
} catch (err: any) {
error.value = err.response?.data?.error || 'Login failed. Please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-container h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1a1a2e;
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.signup-link {
text-align: center;
margin-top: 1.5rem;
color: #666;
}
.signup-link a {
color: #1a1a2e;
text-decoration: none;
font-weight: 600;
}
.signup-link a:hover {
text-decoration: underline;
}
</style>
-229
View File
@@ -1,229 +0,0 @@
<template>
<div class="register-page">
<div class="register-container">
<h1>Create Account</h1>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label for="username">Username</label>
<input
v-model="form.username"
id="username"
type="text"
placeholder="Choose a username"
required
minlength="3"
/>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
v-model="form.email"
id="email"
type="email"
placeholder="Enter your email"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="form.password"
id="password"
type="password"
placeholder="Create a password"
required
minlength="8"
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
v-model="form.confirmPassword"
id="confirmPassword"
type="password"
placeholder="Confirm your password"
required
/>
</div>
<div v-if="error" class="alert alert-error">
{{ error }}
</div>
<div v-if="success" class="alert alert-success">
{{ success }}
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Creating account...' : 'Register' }}
</button>
</form>
<p class="login-link">
Already have an account? <router-link to="/login">Login here</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const handleRegister = async () => {
error.value = ''
success.value = ''
if (form.value.password !== form.value.confirmPassword) {
error.value = 'Passwords do not match'
return
}
loading.value = true
try {
await authStore.register(form.value.username, form.value.email, form.value.password)
success.value = 'Account created successfully! Redirecting to login...'
setTimeout(() => {
router.push('/login')
}, 2000)
} catch (err: any) {
error.value = err.response?.data?.error || 'Registration failed. Please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 75vh;
}
.register-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.register-container h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1a1a2e;
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.login-link {
text-align: center;
margin-top: 1.5rem;
color: #666;
}
.login-link a {
color: #1a1a2e;
text-decoration: none;
font-weight: 600;
}
.login-link a:hover {
text-decoration: underline;
}
</style>
-49
View File
@@ -1,49 +0,0 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Home from '@/pages/Home.vue'
import Login from '@/pages/Login.vue'
import Register from '@/pages/Register.vue'
import Dashboard from '@/pages/Dashboard.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/login',
name: 'Login',
component: Login,
},
{
path: '/register',
name: 'Register',
component: Register,
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if ((to.path === '/login' || to.path === '/register') && authStore.isAuthenticated) {
next('/dashboard')
} else {
next()
}
})
export default router
-11
View File
@@ -1,11 +0,0 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
export default apiClient
-109
View File
@@ -1,109 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiClient from '@/services/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const user = ref<any>(null)
const isAuthenticated = computed(() => !!token.value)
const login = async (username: string, password: string) => {
try {
const response = await apiClient.post('/api/v1/auth/login', {
username,
password,
})
token.value = response.data.token
user.value = {
id: response.data.id,
username: response.data.username,
email: response.data.email,
}
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
return response.data
} catch (error) {
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
throw error
}
}
const register = async (username: string, email: string, password: string) => {
try {
const response = await apiClient.post('/api/v1/auth/register', {
username,
email,
password,
})
return response.data
} catch (error) {
throw error
}
}
const logout = () => {
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
const setupAuthInterceptors = () => {
apiClient.interceptors.request.use((config) => {
if (token.value) {
config.headers.Authorization = `Bearer ${token.value}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
logout()
}
return Promise.reject(error)
}
)
}
const loadSavedAuth = () => {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken) {
token.value = savedToken
}
if (savedUser) {
user.value = JSON.parse(savedUser)
}
}
return {
token,
user,
isAuthenticated,
login,
register,
logout,
setupAuthInterceptors,
loadSavedAuth,
}
}, {
persist: {
enabled: true,
strategies: [
{
key: 'auth',
storage: localStorage,
},
],
},
})
-35
View File
@@ -1,35 +0,0 @@
/**
* API Response types
*/
export interface ApiResponse<T> {
data?: T
error?: string
message?: string
status?: number
}
export interface User {
id: number
username: string
email: string
createdAt?: string
updatedAt?: string
}
export interface AuthResponse {
token: string
id: number
username: string
email: string
}
export interface RegisterRequest {
username: string
email: string
password: string
}
export interface LoginRequest {
username: string
password: string
}
-32
View File
@@ -1,32 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Bundler mode */
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-10
View File
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
-28
View File
@@ -1,28 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
port: 5173,
strictPort: false,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: resolve(__dirname, '../backend/src/main/resources/static'),
emptyOutDir: true,
sourcemap: false,
minify: 'terser',
},
})
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})
-2
View File
@@ -1,2 +0,0 @@
<!-- Vitest Configuration UI -->
export {}
-37
View File
@@ -1,37 +0,0 @@
# Gradle Configuration
org.gradle.jvmargs=-Xmx2g
org.gradle.parallel=true
org.gradle.caching=true
# Project Versions - All compatible with JDK 21
javaVersion=21
springBootVersion=4.0.+
springDependencyManagementVersion=1.1.6
springCloudVersion=2024.0.0
springSecurityVersion=6.3.1
springdocOpenApiVersion=2.5.0
openApiGeneratorVersion=7.6.0
jooqVersion=3.21.0
jooqPluginVersion=9.0
postgresqlVersion=42.7.3
flywayVersion=10.8.1
logbackVersion=1.5.0
logstashLogbackVersion=8.0
auth0-jwtVersion=4.4.0
jacksonVersion=2.18.0
commonsLang3Version=3.14.0
guavaVersion=33.1.0-jre
lombokVersion=1.18.30
junitVersion=5.10.2
mockitoVersion=5.11.0
testcontainersVersion=1.20.0
h2Version=2.3.232
restAssuredVersion=5.4.0
# Frontend
nodeVersion=20.11.0
npmVersion=10.2.4
# Docker Images
postgresDockerVersion=17-alpine
alpineVersion=3.20
Binary file not shown.
+3 -1
View File
@@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable → Regular
+194 -119
View File
@@ -1,78 +1,129 @@
#!/usr/bin/env sh
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
##
## Gradle start up script for UN*X
##
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
MAX_FD=maximum
warn () {
echo "$*"
}
} >&2
die () {
echo
echo "$*"
echo
exit 1
}
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -81,96 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94 -84
View File
@@ -1,84 +1,94 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+15
View File
@@ -0,0 +1,15 @@
pluginManagement {
repositories {
maven{ url 'https://mvnrepository.com/'}
gradlePluginPortal()
// If you have other custom repositories, add them here
}
}
rootProject.name = 'demo'
include 'db'
-4
View File
@@ -1,4 +0,0 @@
rootProject.name = "mtg-search"
include(":backend")
include(":frontend")
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
html, body, #outlet {
height: 100%;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>
@@ -0,0 +1,13 @@
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,2 @@
vaadin.launch-browser=true
spring.application.name=demo
@@ -0,0 +1,13 @@
package com.example.demo;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DemoApplicationTests {
@Test
void contextLoads() {
}
}
-185
View File
@@ -1,185 +0,0 @@
#!/bin/bash
# Startup script for MTG Search Project
# This script helps set up and run the project
set -e
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}=== MTG Search Project Startup ===${NC}"
# Check prerequisites
check_prerequisites() {
echo -e "\n${BLUE}Checking prerequisites...${NC}"
# Check Java
if ! command -v java &> /dev/null; then
echo -e "${RED}✗ Java not found. Please install Java 21+${NC}"
exit 1
fi
JAVA_VERSION=$(java -version 2>&1 | grep -oP 'version "\K[0-9.]+')
echo -e "${GREEN}✓ Java $JAVA_VERSION found${NC}"
# Check Node.js
if ! command -v node &> /dev/null; then
echo -e "${RED}✗ Node.js not found. Please install Node.js 20+${NC}"
exit 1
fi
NODE_VERSION=$(node -v)
echo -e "${GREEN}✓ Node.js $NODE_VERSION found${NC}"
# Check PostgreSQL
if ! command -v psql &> /dev/null; then
if ! command -v docker &> /dev/null; then
echo -e "${RED}✗ PostgreSQL and Docker not found. Please install one of them${NC}"
exit 1
fi
echo -e "${GREEN}✓ Docker found (will use for PostgreSQL)${NC}"
else
PG_VERSION=$(psql --version)
echo -e "${GREEN}$PG_VERSION found${NC}"
fi
}
# Setup database
setup_database() {
echo -e "\n${BLUE}Setting up database...${NC}"
# Check if PostgreSQL is running
if ! psql -U postgres -h localhost -d postgres -c "SELECT 1" &> /dev/null; then
echo -e "${BLUE}Starting PostgreSQL with Docker...${NC}"
if docker ps -a --format '{{.Names}}' | grep -q mtgsearch-postgres; then
docker start mtgsearch-postgres
echo -e "${GREEN}✓ PostgreSQL container started${NC}"
else
docker run --name mtgsearch-postgres \
-e POSTGRES_DB=mtgsearch \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:17-alpine
echo -e "${BLUE}Waiting for PostgreSQL to be ready...${NC}"
sleep 10
echo -e "${GREEN}✓ PostgreSQL container created and started${NC}"
fi
else
echo -e "${GREEN}✓ PostgreSQL is already running${NC}"
fi
# Create database if it doesn't exist
psql -U postgres -h localhost -c "CREATE DATABASE mtgsearch;" 2>/dev/null || true
echo -e "${GREEN}✓ Database ready${NC}"
}
# Build backend
build_backend() {
echo -e "\n${BLUE}Building backend...${NC}"
cd backend
../gradlew clean build -x test
cd ..
echo -e "${GREEN}✓ Backend built successfully${NC}"
}
# Install frontend dependencies
install_frontend() {
echo -e "\n${BLUE}Installing frontend dependencies...${NC}"
cd frontend
npm install
cd ..
echo -e "${GREEN}✓ Frontend dependencies installed${NC}"
}
# Build frontend
build_frontend() {
echo -e "\n${BLUE}Building frontend...${NC}"
cd frontend
npm run build
cd ..
echo -e "${GREEN}✓ Frontend built successfully${NC}"
}
# Display startup options
show_menu() {
echo -e "\n${BLUE}=== Startup Options ===${NC}"
echo "1. Full Setup (database + build everything)"
echo "2. Setup Database Only"
echo "3. Build Backend Only"
echo "4. Build Frontend Only"
echo "5. Run Backend (requires database)"
echo "6. Run Frontend Dev Server"
echo "7. Run Both (Backend + Frontend)"
echo "8. Docker Compose (recommended)"
echo "9. Exit"
echo ""
}
# Main menu
main_menu() {
while true; do
show_menu
read -p "Select option (1-9): " choice
case $choice in
1)
check_prerequisites
setup_database
build_backend
install_frontend
build_frontend
echo -e "\n${GREEN}✓ Setup complete!${NC}"
echo -e "${BLUE}Run 'make run' to start the backend${NC}"
echo -e "${BLUE}In another terminal, run 'cd frontend && npm run dev' for frontend${NC}"
;;
2)
check_prerequisites
setup_database
;;
3)
build_backend
;;
4)
install_frontend
build_frontend
;;
5)
check_prerequisites
echo -e "\n${BLUE}Starting backend on http://localhost:8080${NC}"
cd backend
../gradlew bootRun
;;
6)
echo -e "\n${BLUE}Starting frontend dev server on http://localhost:5173${NC}"
cd frontend
npm run dev
;;
7)
echo -e "\n${BLUE}Starting Backend + Frontend${NC}"
echo -e "${BLUE}Frontend: http://localhost:5173${NC}"
echo -e "${BLUE}Backend: http://localhost:8080${NC}"
(cd backend && ../gradlew bootRun) &
(cd frontend && npm run dev) &
wait
;;
8)
echo -e "\n${BLUE}Starting with Docker Compose${NC}"
docker-compose up --build
;;
9)
echo -e "${GREEN}Goodbye!${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid option. Please try again.${NC}"
;;
esac
done
}
# Run main menu
main_menu