63
DEPLOY.md
63
DEPLOY.md
@@ -1,63 +0,0 @@
|
|||||||
# Deployment Guide for Filamenteka
|
|
||||||
|
|
||||||
## Important: API Update Required
|
|
||||||
|
|
||||||
The production API at api.filamenteka.rs needs to be updated with the latest code changes.
|
|
||||||
|
|
||||||
### Changes that need to be deployed:
|
|
||||||
|
|
||||||
1. **Database Schema Changes**:
|
|
||||||
- Column renamed from `vakum` to `spulna`
|
|
||||||
- Column `otvoreno` has been removed
|
|
||||||
- Data types changed from strings to integers for `refill` and `spulna`
|
|
||||||
- Added CHECK constraint: `kolicina = refill + spulna`
|
|
||||||
|
|
||||||
2. **API Server Changes**:
|
|
||||||
- Updated `/api/filaments` endpoints to use new column names
|
|
||||||
- Updated data type handling (integers instead of strings)
|
|
||||||
- Added proper quantity calculation
|
|
||||||
|
|
||||||
### Deployment Steps:
|
|
||||||
|
|
||||||
1. **Update the API server code**:
|
|
||||||
```bash
|
|
||||||
# On the production server
|
|
||||||
cd /path/to/api
|
|
||||||
git pull origin main
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run database migrations**:
|
|
||||||
```bash
|
|
||||||
# Run the migration to rename columns
|
|
||||||
psql $DATABASE_URL < database/migrations/003_rename_vakum_to_spulna.sql
|
|
||||||
|
|
||||||
# Run the migration to fix data types
|
|
||||||
psql $DATABASE_URL < database/migrations/004_fix_inventory_data_types.sql
|
|
||||||
|
|
||||||
# Fix any data inconsistencies
|
|
||||||
psql $DATABASE_URL < database/migrations/fix_quantity_consistency.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Restart the API server**:
|
|
||||||
```bash
|
|
||||||
# Restart the service
|
|
||||||
pm2 restart filamenteka-api
|
|
||||||
# or
|
|
||||||
systemctl restart filamenteka-api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Temporary Frontend Compatibility
|
|
||||||
|
|
||||||
The frontend has been updated to handle both old and new API response formats, so it will work with both:
|
|
||||||
- Old format: `vakum`, `otvoreno` (strings)
|
|
||||||
- New format: `spulna` (integer), no `otvoreno` field
|
|
||||||
|
|
||||||
Once the API is updated, the compatibility layer can be removed.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
After deployment, verify:
|
|
||||||
1. API returns `spulna` instead of `vakum`
|
|
||||||
2. Values are integers, not strings
|
|
||||||
3. Quantity calculations are correct (`kolicina = refill + spulna`)
|
|
||||||
101
DEPLOYMENT.md
101
DEPLOYMENT.md
@@ -1,101 +0,0 @@
|
|||||||
# Filamenteka Deployment Guide
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Filamenteka now uses:
|
|
||||||
- **Frontend**: Next.js deployed on AWS Amplify
|
|
||||||
- **Database**: PostgreSQL on AWS RDS (publicly accessible)
|
|
||||||
- **API**: Node.js server that can be run locally or deployed anywhere
|
|
||||||
|
|
||||||
## AWS RDS Setup
|
|
||||||
|
|
||||||
1. Navigate to the terraform directory:
|
|
||||||
```bash
|
|
||||||
cd terraform
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Initialize Terraform:
|
|
||||||
```bash
|
|
||||||
terraform init
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Apply the infrastructure:
|
|
||||||
```bash
|
|
||||||
terraform apply
|
|
||||||
```
|
|
||||||
|
|
||||||
4. After deployment, get the database connection details:
|
|
||||||
```bash
|
|
||||||
terraform output -json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Get the database password from AWS Secrets Manager:
|
|
||||||
```bash
|
|
||||||
aws secretsmanager get-secret-value --secret-id filamenteka-db-credentials --query SecretString --output text | jq -r .password
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the API
|
|
||||||
|
|
||||||
### Option 1: Local Development
|
|
||||||
|
|
||||||
1. Create `.env` file in the `api` directory:
|
|
||||||
```
|
|
||||||
DATABASE_URL=postgresql://filamenteka_admin:[PASSWORD]@[RDS_ENDPOINT]/filamenteka
|
|
||||||
JWT_SECRET=your-secret-key-here
|
|
||||||
ADMIN_PASSWORD=your-password-here
|
|
||||||
PORT=4000
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies and run migrations:
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npm install
|
|
||||||
npm run migrate
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Deploy on a VPS/Cloud Service
|
|
||||||
|
|
||||||
You can deploy the Node.js API on:
|
|
||||||
- Heroku
|
|
||||||
- Railway
|
|
||||||
- Render
|
|
||||||
- AWS EC2
|
|
||||||
- DigitalOcean
|
|
||||||
- Any VPS with Node.js
|
|
||||||
|
|
||||||
Just ensure the `DATABASE_URL` points to your RDS instance.
|
|
||||||
|
|
||||||
## Frontend Configuration
|
|
||||||
|
|
||||||
Update `.env.local` to point to your API:
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api # For local
|
|
||||||
# or
|
|
||||||
NEXT_PUBLIC_API_URL=https://your-api-domain.com/api # For production
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
1. **RDS Security**: The current configuration allows access from anywhere (0.0.0.0/0). In production:
|
|
||||||
- Update the security group to only allow your IP addresses
|
|
||||||
- Or use a VPN/bastion host
|
|
||||||
- Or deploy the API in the same VPC and restrict access
|
|
||||||
|
|
||||||
2. **API Security**:
|
|
||||||
- Change the default admin password
|
|
||||||
- Use strong JWT secrets
|
|
||||||
- Enable HTTPS in production
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
Connect to the PostgreSQL database using any client:
|
|
||||||
```
|
|
||||||
psql postgresql://filamenteka_admin:[PASSWORD]@[RDS_ENDPOINT]/filamenteka
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use a GUI tool like:
|
|
||||||
- pgAdmin
|
|
||||||
- TablePlus
|
|
||||||
- DBeaver
|
|
||||||
- DataGrip
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Project Structure
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Filamenteka is organized with clear separation between frontend, API, and infrastructure code.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
filamenteka/
|
|
||||||
├── app/ # Next.js app directory
|
|
||||||
│ ├── page.tsx # Main page
|
|
||||||
│ ├── layout.tsx # Root layout
|
|
||||||
│ └── upadaj/ # Admin pages
|
|
||||||
│ ├── page.tsx # Admin login
|
|
||||||
│ ├── dashboard/ # Filament management
|
|
||||||
│ └── colors/ # Color management
|
|
||||||
│
|
|
||||||
├── src/ # Source code
|
|
||||||
│ ├── components/ # React components
|
|
||||||
│ │ ├── FilamentTableV2.tsx
|
|
||||||
│ │ ├── EnhancedFilters.tsx
|
|
||||||
│ │ ├── ColorSwatch.tsx
|
|
||||||
│ │ ├── InventoryBadge.tsx
|
|
||||||
│ │ └── MaterialBadge.tsx
|
|
||||||
│ ├── types/ # TypeScript types
|
|
||||||
│ │ ├── filament.ts
|
|
||||||
│ │ └── filament.v2.ts
|
|
||||||
│ ├── services/ # API services
|
|
||||||
│ │ └── api.ts
|
|
||||||
│ └── styles/ # Component styles
|
|
||||||
│ ├── index.css
|
|
||||||
│ └── select.css
|
|
||||||
│
|
|
||||||
├── api/ # Node.js Express API
|
|
||||||
│ ├── server.js # Express server
|
|
||||||
│ ├── migrate.js # Database migration script
|
|
||||||
│ ├── package.json # API dependencies
|
|
||||||
│ └── Dockerfile # Docker configuration
|
|
||||||
│
|
|
||||||
├── database/ # Database schemas
|
|
||||||
│ └── schema.sql # PostgreSQL schema
|
|
||||||
│
|
|
||||||
├── terraform/ # Infrastructure as Code
|
|
||||||
│ ├── main.tf # Main configuration
|
|
||||||
│ ├── vpc.tf # VPC and networking
|
|
||||||
│ ├── rds.tf # PostgreSQL RDS
|
|
||||||
│ ├── ec2-api.tf # EC2 for API server
|
|
||||||
│ ├── alb.tf # Application Load Balancer
|
|
||||||
│ ├── ecr.tf # Docker registry
|
|
||||||
│ ├── cloudflare-api.tf # Cloudflare DNS
|
|
||||||
│ └── variables.tf # Variable definitions
|
|
||||||
│
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
│ ├── security/ # Security checks
|
|
||||||
│ │ └── security-check.js
|
|
||||||
│ └── pre-commit.sh # Git pre-commit hook
|
|
||||||
│
|
|
||||||
├── config/ # Configuration files
|
|
||||||
│ └── environments.js # Environment configuration
|
|
||||||
│
|
|
||||||
└── public/ # Static assets
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Files
|
|
||||||
|
|
||||||
- `.env.development` - Development environment variables
|
|
||||||
- `.env.production` - Production environment variables
|
|
||||||
- `.env.local` - Local overrides (not committed)
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- **Frontend**: Next.js static site hosted on AWS Amplify
|
|
||||||
- **API**: Node.js Express server running on EC2
|
|
||||||
- **Database**: PostgreSQL on AWS RDS
|
|
||||||
- **HTTPS**: Application Load Balancer with ACM certificate
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
1. Frontend (Next.js) → HTTPS API (ALB) → Express Server (EC2) → PostgreSQL (RDS)
|
|
||||||
2. Authentication via JWT tokens
|
|
||||||
3. Real-time database synchronization
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
- Managed via Terraform
|
|
||||||
- AWS services: RDS, EC2, ALB, VPC, ECR, Amplify
|
|
||||||
- Cloudflare for DNS management
|
|
||||||
- Docker for API containerization
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
1. **Local Development**
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Deploy Infrastructure**
|
|
||||||
```bash
|
|
||||||
cd terraform
|
|
||||||
terraform apply
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Deploy API Updates**
|
|
||||||
- API automatically pulls latest Docker image every 5 minutes
|
|
||||||
- Or manually: SSH to EC2 and run deployment script
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
### Run Migrations
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
npm run migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Connect to Database
|
|
||||||
```bash
|
|
||||||
psql postgresql://user:pass@rds-endpoint/filamenteka
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- No hardcoded credentials
|
|
||||||
- JWT authentication for admin
|
|
||||||
- Environment-specific configurations
|
|
||||||
- Pre-commit security checks
|
|
||||||
- HTTPS everywhere
|
|
||||||
- VPC isolation for backend services
|
|
||||||
230
__tests__/api.test.ts
Normal file
230
__tests__/api.test.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import api, { authService, colorService, filamentService } from '../src/services/api';
|
||||||
|
|
||||||
|
// Get the mock axios instance that was created
|
||||||
|
const mockAxiosInstance = (axios.create as jest.Mock).mock.results[0].value;
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: jest.fn(),
|
||||||
|
setItem: jest.fn(),
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
clear: jest.fn(),
|
||||||
|
};
|
||||||
|
(global as any).localStorage = localStorageMock;
|
||||||
|
|
||||||
|
// Mock window.location
|
||||||
|
const mockLocation = {
|
||||||
|
pathname: '/',
|
||||||
|
href: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only define location if it doesn't exist or is configurable
|
||||||
|
if (!Object.getOwnPropertyDescriptor(window, 'location') ||
|
||||||
|
Object.getOwnPropertyDescriptor(window, 'location')?.configurable) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: mockLocation,
|
||||||
|
configurable: true,
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If location exists and is not configurable, we'll work with the existing object
|
||||||
|
Object.assign(window.location, mockLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('API Service Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Clear localStorage mocks
|
||||||
|
localStorageMock.getItem.mockClear();
|
||||||
|
localStorageMock.removeItem.mockClear();
|
||||||
|
localStorageMock.setItem.mockClear();
|
||||||
|
|
||||||
|
// Reset window location
|
||||||
|
mockLocation.pathname = '/';
|
||||||
|
mockLocation.href = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auth Service', () => {
|
||||||
|
it('should login successfully', async () => {
|
||||||
|
const mockResponse = { data: { token: 'test-token', user: 'admin' } };
|
||||||
|
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await authService.login('admin', 'password123');
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/login', {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle login failure', async () => {
|
||||||
|
const error = new Error('Invalid credentials');
|
||||||
|
mockAxiosInstance.post.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(authService.login('admin', 'wrong')).rejects.toThrow('Invalid credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Color Service', () => {
|
||||||
|
it('should get all colors', async () => {
|
||||||
|
const mockColors = [
|
||||||
|
{ id: '1', name: 'Red', hex: '#FF0000' },
|
||||||
|
{ id: '2', name: 'Blue', hex: '#0000FF' }
|
||||||
|
];
|
||||||
|
mockAxiosInstance.get.mockResolvedValue({ data: mockColors });
|
||||||
|
|
||||||
|
const result = await colorService.getAll();
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/colors');
|
||||||
|
expect(result).toEqual(mockColors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a color', async () => {
|
||||||
|
const newColor = { name: 'Green', hex: '#00FF00', cena_refill: 100, cena_spulna: 150 };
|
||||||
|
const mockResponse = { id: '3', ...newColor };
|
||||||
|
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await colorService.create(newColor);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/colors', newColor);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a color', async () => {
|
||||||
|
const colorId = '1';
|
||||||
|
const updateData = { name: 'Dark Red', hex: '#8B0000' };
|
||||||
|
const mockResponse = { id: colorId, ...updateData };
|
||||||
|
mockAxiosInstance.put.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await colorService.update(colorId, updateData);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.put).toHaveBeenCalledWith(`/colors/${colorId}`, updateData);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a color', async () => {
|
||||||
|
const colorId = '1';
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await colorService.delete(colorId);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(`/colors/${colorId}`);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filament Service', () => {
|
||||||
|
it('should get all filaments with cache buster', async () => {
|
||||||
|
const mockFilaments = [
|
||||||
|
{ id: '1', tip: 'PLA', boja: 'Red' },
|
||||||
|
{ id: '2', tip: 'PETG', boja: 'Blue' }
|
||||||
|
];
|
||||||
|
mockAxiosInstance.get.mockResolvedValue({ data: mockFilaments });
|
||||||
|
|
||||||
|
// Mock Date.now()
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
const mockTimestamp = 1234567890;
|
||||||
|
Date.now = jest.fn(() => mockTimestamp);
|
||||||
|
|
||||||
|
const result = await filamentService.getAll();
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.get).toHaveBeenCalledWith(`/filaments?_t=${mockTimestamp}`);
|
||||||
|
expect(result).toEqual(mockFilaments);
|
||||||
|
|
||||||
|
// Restore Date.now
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a filament', async () => {
|
||||||
|
const newFilament = {
|
||||||
|
tip: 'ABS',
|
||||||
|
finish: 'Matte',
|
||||||
|
boja: 'Black',
|
||||||
|
boja_hex: '#000000'
|
||||||
|
};
|
||||||
|
const mockResponse = { id: '3', ...newFilament };
|
||||||
|
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await filamentService.create(newFilament);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/filaments', newFilament);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a filament', async () => {
|
||||||
|
const filamentId = '1';
|
||||||
|
const updateData = {
|
||||||
|
tip: 'PLA+',
|
||||||
|
finish: 'Silk',
|
||||||
|
cena: '4500'
|
||||||
|
};
|
||||||
|
const mockResponse = { id: filamentId, ...updateData };
|
||||||
|
mockAxiosInstance.put.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await filamentService.update(filamentId, updateData);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.put).toHaveBeenCalledWith(`/filaments/${filamentId}`, updateData);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a filament', async () => {
|
||||||
|
const filamentId = '1';
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await filamentService.delete(filamentId);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(`/filaments/${filamentId}`);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update bulk sale', async () => {
|
||||||
|
const saleData = {
|
||||||
|
filamentIds: ['1', '2', '3'],
|
||||||
|
salePercentage: 20,
|
||||||
|
saleStartDate: '2024-01-01',
|
||||||
|
saleEndDate: '2024-01-31',
|
||||||
|
enableSale: true
|
||||||
|
};
|
||||||
|
const mockResponse = { updated: 3, success: true };
|
||||||
|
mockAxiosInstance.post.mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
|
const result = await filamentService.updateBulkSale(saleData);
|
||||||
|
|
||||||
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/filaments/sale/bulk', saleData);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interceptors', () => {
|
||||||
|
it('should have interceptors configured', () => {
|
||||||
|
expect(mockAxiosInstance.interceptors).toBeDefined();
|
||||||
|
expect(mockAxiosInstance.interceptors.request).toBeDefined();
|
||||||
|
expect(mockAxiosInstance.interceptors.response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have request interceptor set up', () => {
|
||||||
|
const mockRequestUse = mockAxiosInstance.interceptors.request.use;
|
||||||
|
expect(mockRequestUse).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have response interceptor set up', () => {
|
||||||
|
const mockResponseUse = mockAxiosInstance.interceptors.response.use;
|
||||||
|
expect(mockResponseUse).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API configuration', () => {
|
||||||
|
it('should export the configured axios instance', () => {
|
||||||
|
expect(api).toBe(mockAxiosInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have axios instance defined', () => {
|
||||||
|
expect(mockAxiosInstance).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
148
__tests__/components/BackToTop.test.tsx
Normal file
148
__tests__/components/BackToTop.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { BackToTop } from '@/src/components/BackToTop';
|
||||||
|
|
||||||
|
// Mock window properties
|
||||||
|
global.scrollTo = jest.fn();
|
||||||
|
Object.defineProperty(window, 'pageYOffset', {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BackToTop', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
window.pageYOffset = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render button when at top of page', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
expect(button).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders button when scrolled down', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Simulate scroll down
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides button when scrolled back up', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Scroll down first
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('button')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Scroll back up
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 100;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls to top when clicked', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Make button visible
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
fireEvent.click(button!);
|
||||||
|
|
||||||
|
expect(global.scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct styling when visible', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Make button visible
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
|
||||||
|
expect(button).toHaveClass('fixed');
|
||||||
|
expect(button).toHaveClass('bottom-8');
|
||||||
|
expect(button).toHaveClass('right-8');
|
||||||
|
expect(button).toHaveClass('bg-blue-600');
|
||||||
|
expect(button).toHaveClass('text-white');
|
||||||
|
expect(button).toHaveClass('rounded-full');
|
||||||
|
expect(button).toHaveClass('shadow-lg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has hover effect', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Make button visible
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
expect(button).toHaveClass('hover:bg-blue-700');
|
||||||
|
expect(button).toHaveClass('hover:scale-110');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains arrow icon', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Make button visible
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg).toBeInTheDocument();
|
||||||
|
expect(svg).toHaveClass('w-6');
|
||||||
|
expect(svg).toHaveClass('h-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-label for accessibility', () => {
|
||||||
|
const { container } = render(<BackToTop />);
|
||||||
|
|
||||||
|
// Make button visible
|
||||||
|
act(() => {
|
||||||
|
window.pageYOffset = 400;
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector('button');
|
||||||
|
expect(button).toHaveAttribute('aria-label', 'Back to top');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up scroll listener on unmount', () => {
|
||||||
|
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||||
|
const { unmount } = render(<BackToTop />);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||||
|
removeEventListenerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
82
__tests__/components/ColorCell.test.tsx
Normal file
82
__tests__/components/ColorCell.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { ColorCell } from '@/src/components/ColorCell';
|
||||||
|
|
||||||
|
// Mock the bambuLabColors module
|
||||||
|
jest.mock('@/src/data/bambuLabColors', () => ({
|
||||||
|
getFilamentColor: jest.fn((colorName) => {
|
||||||
|
const colors = {
|
||||||
|
'Black': { hex: '#000000' },
|
||||||
|
'Red': { hex: '#FF0000' },
|
||||||
|
'Blue': { hex: '#0000FF' },
|
||||||
|
'Rainbow': { hex: ['#FF0000', '#00FF00'], isGradient: true }
|
||||||
|
};
|
||||||
|
return colors[colorName] || { hex: '#CCCCCC' };
|
||||||
|
}),
|
||||||
|
getColorStyle: jest.fn((colorMapping) => {
|
||||||
|
if (colorMapping.isGradient && Array.isArray(colorMapping.hex)) {
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(90deg, ${colorMapping.hex[0]} 0%, ${colorMapping.hex[1]} 100%)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backgroundColor: Array.isArray(colorMapping.hex) ? colorMapping.hex[0] : colorMapping.hex
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ColorCell', () => {
|
||||||
|
it('renders color name', () => {
|
||||||
|
const { getByText } = render(<ColorCell colorName="Black" />);
|
||||||
|
expect(getByText('Black')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders color swatch with correct style', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Red" />);
|
||||||
|
const colorDiv = container.querySelector('.w-6.h-6');
|
||||||
|
expect(colorDiv).toHaveStyle({ backgroundColor: '#FF0000' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with correct dimensions', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Blue" />);
|
||||||
|
const colorDiv = container.querySelector('.w-6.h-6');
|
||||||
|
expect(colorDiv).toHaveClass('w-6');
|
||||||
|
expect(colorDiv).toHaveClass('h-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has rounded corners and border', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Black" />);
|
||||||
|
const colorDiv = container.querySelector('.w-6.h-6');
|
||||||
|
expect(colorDiv).toHaveClass('rounded');
|
||||||
|
expect(colorDiv).toHaveClass('border');
|
||||||
|
expect(colorDiv).toHaveClass('border-gray-300');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders gradient colors', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Rainbow" />);
|
||||||
|
const colorDiv = container.querySelector('.w-6.h-6');
|
||||||
|
expect(colorDiv).toHaveStyle({
|
||||||
|
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has title attribute with hex value', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Black" />);
|
||||||
|
const colorDiv = container.querySelector('.w-6.h-6');
|
||||||
|
expect(colorDiv).toHaveAttribute('title', '#000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has title attribute with gradient hex values', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Rainbow" />);
|
||||||
|
const colorDiv = container.querySelector('.w-6.h-6');
|
||||||
|
expect(colorDiv).toHaveAttribute('title', '#FF0000 - #00FF00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with flex layout', () => {
|
||||||
|
const { container } = render(<ColorCell colorName="Black" />);
|
||||||
|
const wrapper = container.firstChild;
|
||||||
|
expect(wrapper).toHaveClass('flex');
|
||||||
|
expect(wrapper).toHaveClass('items-center');
|
||||||
|
expect(wrapper).toHaveClass('gap-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
87
__tests__/components/MaterialBadge.test.tsx
Normal file
87
__tests__/components/MaterialBadge.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { MaterialBadge } from '@/src/components/MaterialBadge';
|
||||||
|
|
||||||
|
describe('MaterialBadge', () => {
|
||||||
|
describe('Material type badges', () => {
|
||||||
|
it('renders PLA badge with correct style', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||||
|
const badge = getByText('PLA');
|
||||||
|
expect(badge).toHaveClass('bg-green-100');
|
||||||
|
expect(badge).toHaveClass('text-green-800');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders PETG badge with correct style', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PETG" />);
|
||||||
|
const badge = getByText('PETG');
|
||||||
|
expect(badge).toHaveClass('bg-blue-100');
|
||||||
|
expect(badge).toHaveClass('text-blue-800');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ABS badge with correct style', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="ABS" />);
|
||||||
|
const badge = getByText('ABS');
|
||||||
|
expect(badge).toHaveClass('bg-red-100');
|
||||||
|
expect(badge).toHaveClass('text-red-800');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders TPU badge with correct style', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="TPU" />);
|
||||||
|
const badge = getByText('TPU');
|
||||||
|
expect(badge).toHaveClass('bg-purple-100');
|
||||||
|
expect(badge).toHaveClass('text-purple-800');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Unknown material type', () => {
|
||||||
|
it('renders unknown material with default style', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="UNKNOWN" />);
|
||||||
|
const badge = getByText('UNKNOWN');
|
||||||
|
expect(badge).toHaveClass('bg-gray-100');
|
||||||
|
expect(badge).toHaveClass('text-gray-800');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('With modifier', () => {
|
||||||
|
it('renders base and modifier', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PLA" modifier="Silk" />);
|
||||||
|
expect(getByText('PLA')).toBeInTheDocument();
|
||||||
|
expect(getByText('Silk')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modifier with correct style', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PLA" modifier="Matte" />);
|
||||||
|
const modifier = getByText('Matte');
|
||||||
|
expect(modifier).toHaveClass('bg-gray-100');
|
||||||
|
expect(modifier).toHaveClass('text-gray-800');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Common styles', () => {
|
||||||
|
it('has correct padding and shape', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||||
|
const badge = getByText('PLA');
|
||||||
|
expect(badge).toHaveClass('px-2.5');
|
||||||
|
expect(badge).toHaveClass('py-0.5');
|
||||||
|
expect(badge).toHaveClass('rounded-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct text size', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||||
|
const badge = getByText('PLA');
|
||||||
|
expect(badge).toHaveClass('text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct font weight', () => {
|
||||||
|
const { getByText } = render(<MaterialBadge base="PLA" />);
|
||||||
|
const badge = getByText('PLA');
|
||||||
|
expect(badge).toHaveClass('font-medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts custom className', () => {
|
||||||
|
const { container } = render(<MaterialBadge base="PLA" className="custom-class" />);
|
||||||
|
const wrapper = container.firstChild;
|
||||||
|
expect(wrapper).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
100
__tests__/data/bambuLabColors.test.ts
Normal file
100
__tests__/data/bambuLabColors.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { bambuLabColors, getFilamentColor, getColorStyle, ColorMapping } from '@/src/data/bambuLabColors';
|
||||||
|
|
||||||
|
describe('Bambu Lab Colors Data', () => {
|
||||||
|
describe('bambuLabColors', () => {
|
||||||
|
it('should have color definitions', () => {
|
||||||
|
expect(Object.keys(bambuLabColors).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct structure for each color', () => {
|
||||||
|
Object.entries(bambuLabColors).forEach(([colorName, colorMapping]) => {
|
||||||
|
expect(colorMapping).toHaveProperty('hex');
|
||||||
|
expect(colorMapping.hex).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have known colors', () => {
|
||||||
|
expect(bambuLabColors).toHaveProperty('Black');
|
||||||
|
expect(bambuLabColors).toHaveProperty('White');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Red');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Blue');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Green');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid hex colors', () => {
|
||||||
|
Object.entries(bambuLabColors).forEach(([colorName, colorMapping]) => {
|
||||||
|
if (typeof colorMapping.hex === 'string') {
|
||||||
|
expect(colorMapping.hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
} else if (Array.isArray(colorMapping.hex)) {
|
||||||
|
colorMapping.hex.forEach(hex => {
|
||||||
|
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Unknown fallback color', () => {
|
||||||
|
expect(bambuLabColors).toHaveProperty('Unknown');
|
||||||
|
expect(bambuLabColors.Unknown.hex).toBe('#CCCCCC');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFilamentColor', () => {
|
||||||
|
it('should return exact match', () => {
|
||||||
|
const result = getFilamentColor('Black');
|
||||||
|
expect(result).toEqual(bambuLabColors['Black']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return case-insensitive match', () => {
|
||||||
|
const result = getFilamentColor('black');
|
||||||
|
expect(result).toEqual(bambuLabColors['Black']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return partial match', () => {
|
||||||
|
const result = getFilamentColor('PLA Black');
|
||||||
|
expect(result).toEqual(bambuLabColors['Black']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default color for non-existent color', () => {
|
||||||
|
const result = getFilamentColor('NonExistentColor');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result).toHaveProperty('hex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = getFilamentColor('');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result).toHaveProperty('hex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getColorStyle', () => {
|
||||||
|
it('should return backgroundColor for single hex color', () => {
|
||||||
|
const colorMapping: ColorMapping = { hex: '#FF0000' };
|
||||||
|
const result = getColorStyle(colorMapping);
|
||||||
|
expect(result).toEqual({ backgroundColor: '#FF0000' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return backgroundColor for hex array without gradient', () => {
|
||||||
|
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00'] };
|
||||||
|
const result = getColorStyle(colorMapping);
|
||||||
|
expect(result).toEqual({ backgroundColor: '#FF0000' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return gradient for isGradient true', () => {
|
||||||
|
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00'], isGradient: true };
|
||||||
|
const result = getColorStyle(colorMapping);
|
||||||
|
expect(result).toEqual({
|
||||||
|
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle gradient with more than 2 colors', () => {
|
||||||
|
const colorMapping: ColorMapping = { hex: ['#FF0000', '#00FF00', '#0000FF'], isGradient: true };
|
||||||
|
const result = getColorStyle(colorMapping);
|
||||||
|
expect(result).toEqual({
|
||||||
|
background: 'linear-gradient(90deg, #FF0000 0%, #00FF00 100%)'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
__tests__/data/bambuLabColorsComplete.test.ts
Normal file
68
__tests__/data/bambuLabColorsComplete.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { bambuLabColors, colorsByFinish, getColorHex, getColorsForFinish } from '@/src/data/bambuLabColorsComplete';
|
||||||
|
|
||||||
|
describe('Bambu Lab Colors Complete Data', () => {
|
||||||
|
it('should have color definitions', () => {
|
||||||
|
expect(bambuLabColors).toBeDefined();
|
||||||
|
expect(typeof bambuLabColors).toBe('object');
|
||||||
|
expect(Object.keys(bambuLabColors).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have basic colors', () => {
|
||||||
|
expect(bambuLabColors).toHaveProperty('Black');
|
||||||
|
expect(bambuLabColors).toHaveProperty('White');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Red');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Blue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have matte colors', () => {
|
||||||
|
expect(bambuLabColors).toHaveProperty('Matte Black');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Matte White');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Matte Red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have silk colors', () => {
|
||||||
|
expect(bambuLabColors).toHaveProperty('Silk White');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Silk Black');
|
||||||
|
expect(bambuLabColors).toHaveProperty('Silk Gold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid hex colors', () => {
|
||||||
|
Object.entries(bambuLabColors).forEach(([colorName, hex]) => {
|
||||||
|
expect(hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have colorsByFinish defined', () => {
|
||||||
|
expect(colorsByFinish).toBeDefined();
|
||||||
|
expect(typeof colorsByFinish).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have finish categories', () => {
|
||||||
|
expect(colorsByFinish).toHaveProperty('Basic');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Matte');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Silk');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Metal');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Sparkle');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Glow');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Transparent');
|
||||||
|
expect(colorsByFinish).toHaveProperty('Support');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return valid hex for getColorHex', () => {
|
||||||
|
const hex = getColorHex('Black');
|
||||||
|
expect(hex).toBe('#000000');
|
||||||
|
|
||||||
|
const unknownHex = getColorHex('Unknown Color');
|
||||||
|
expect(unknownHex).toBe('#000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return colors for finish', () => {
|
||||||
|
const basicColors = getColorsForFinish('Basic');
|
||||||
|
expect(Array.isArray(basicColors)).toBe(true);
|
||||||
|
expect(basicColors.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const unknownFinish = getColorsForFinish('Unknown');
|
||||||
|
expect(Array.isArray(unknownFinish)).toBe(true);
|
||||||
|
expect(unknownFinish.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -154,16 +154,6 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAllData();
|
fetchAllData();
|
||||||
|
|
||||||
// Refresh when window regains focus
|
|
||||||
const handleFocus = () => {
|
|
||||||
fetchAllData();
|
|
||||||
};
|
|
||||||
window.addEventListener('focus', handleFocus);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('focus', handleFocus);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sorting logic
|
// Sorting logic
|
||||||
@@ -721,7 +711,21 @@ function FilamentForm({
|
|||||||
cena_refill: refillPrice || 3499,
|
cena_refill: refillPrice || 3499,
|
||||||
cena_spulna: spulnaPrice || 3999,
|
cena_spulna: spulnaPrice || 3999,
|
||||||
});
|
});
|
||||||
}, [filament, availableColors]);
|
}, [filament]);
|
||||||
|
|
||||||
|
// Update prices when color selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.boja && availableColors.length > 0) {
|
||||||
|
const colorData = availableColors.find(c => c.name === formData.boja);
|
||||||
|
if (colorData) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
cena_refill: colorData.cena_refill || prev.cena_refill,
|
||||||
|
cena_spulna: colorData.cena_spulna || prev.cena_spulna,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formData.boja, availableColors.length]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ INSERT INTO colors (name, hex) VALUES
|
|||||||
('Silver', '#A6A9AA'),
|
('Silver', '#A6A9AA'),
|
||||||
('Blue Grey', '#5B6579'),
|
('Blue Grey', '#5B6579'),
|
||||||
('Dark Gray', '#555555'),
|
('Dark Gray', '#555555'),
|
||||||
('Black', '#000000')
|
('Black', '#000000'),
|
||||||
|
('Latte Brown', '#D3B7A7')
|
||||||
ON CONFLICT (name)
|
ON CONFLICT (name)
|
||||||
DO UPDATE SET hex = EXCLUDED.hex;
|
DO UPDATE SET hex = EXCLUDED.hex;
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ WHERE c.name IN (
|
|||||||
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
||||||
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
||||||
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
||||||
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black'
|
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM filaments f
|
SELECT 1 FROM filaments f
|
||||||
@@ -75,7 +76,7 @@ AND boja IN (
|
|||||||
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
||||||
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
||||||
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
||||||
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black'
|
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Zero out ALL other filaments (not PLA Basic with these specific colors)
|
-- Zero out ALL other filaments (not PLA Basic with these specific colors)
|
||||||
@@ -90,6 +91,6 @@ WHERE NOT (
|
|||||||
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
'Mistletoe Green', 'Pink', 'Hot Pink', 'Magenta', 'Red',
|
||||||
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
'Maroon Red', 'Purple', 'Indigo Purple', 'Turquoise', 'Cyan',
|
||||||
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
'Cobalt Blue', 'Blue', 'Brown', 'Cocoa Brown', 'Bronze',
|
||||||
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black'
|
'Gray', 'Silver', 'Blue Grey', 'Dark Gray', 'Black', 'Latte Brown'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# Improved Data Structure Proposal
|
|
||||||
|
|
||||||
## Current Issues
|
|
||||||
1. Mixed languages (English/Serbian)
|
|
||||||
2. String fields for numeric/boolean values
|
|
||||||
3. Inconsistent status representation
|
|
||||||
4. No proper inventory tracking
|
|
||||||
5. Missing important metadata
|
|
||||||
|
|
||||||
## Proposed Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Filament {
|
|
||||||
// Identifiers
|
|
||||||
id: string;
|
|
||||||
sku?: string; // For internal tracking
|
|
||||||
|
|
||||||
// Product Info
|
|
||||||
brand: string;
|
|
||||||
type: 'PLA' | 'PETG' | 'ABS' | 'TPU' | 'SILK' | 'CF' | 'WOOD';
|
|
||||||
material: {
|
|
||||||
base: 'PLA' | 'PETG' | 'ABS' | 'TPU';
|
|
||||||
modifier?: 'Silk' | 'Matte' | 'Glow' | 'Wood' | 'CF';
|
|
||||||
};
|
|
||||||
color: {
|
|
||||||
name: string;
|
|
||||||
hex?: string; // Color code for UI display
|
|
||||||
pantone?: string; // For color matching
|
|
||||||
};
|
|
||||||
|
|
||||||
// Physical Properties
|
|
||||||
weight: {
|
|
||||||
value: number; // 1000 for 1kg, 500 for 0.5kg
|
|
||||||
unit: 'g' | 'kg';
|
|
||||||
};
|
|
||||||
diameter: number; // 1.75 or 2.85
|
|
||||||
|
|
||||||
// Inventory Status
|
|
||||||
inventory: {
|
|
||||||
total: number; // Total spools
|
|
||||||
available: number; // Available for use
|
|
||||||
inUse: number; // Currently being used
|
|
||||||
locations: {
|
|
||||||
vacuum: number; // In vacuum storage
|
|
||||||
opened: number; // Opened but usable
|
|
||||||
printer: number; // Loaded in printer
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Purchase Info
|
|
||||||
pricing: {
|
|
||||||
purchasePrice?: number;
|
|
||||||
currency: 'RSD' | 'EUR' | 'USD';
|
|
||||||
supplier?: string;
|
|
||||||
purchaseDate?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Condition
|
|
||||||
condition: {
|
|
||||||
isRefill: boolean;
|
|
||||||
openedDate?: string;
|
|
||||||
expiryDate?: string;
|
|
||||||
storageCondition: 'vacuum' | 'sealed' | 'opened' | 'desiccant';
|
|
||||||
humidity?: number; // Last measured
|
|
||||||
};
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
tags: string[]; // ['premium', 'engineering', 'easy-print']
|
|
||||||
notes?: string; // Special handling instructions
|
|
||||||
images?: string[]; // S3 URLs for photos
|
|
||||||
|
|
||||||
// Timestamps
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastUsed?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### 1. **Better Filtering**
|
|
||||||
```typescript
|
|
||||||
// Find all sealed PLA under 1kg
|
|
||||||
filaments.filter(f =>
|
|
||||||
f.material.base === 'PLA' &&
|
|
||||||
f.weight.value <= 1000 &&
|
|
||||||
f.condition.storageCondition === 'vacuum'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Inventory Management**
|
|
||||||
```typescript
|
|
||||||
// Get total available filament weight
|
|
||||||
const totalWeight = filaments.reduce((sum, f) =>
|
|
||||||
sum + (f.inventory.available * f.weight.value), 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find low stock items
|
|
||||||
const lowStock = filaments.filter(f =>
|
|
||||||
f.inventory.available <= 1 && f.inventory.total > 0
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Color Management**
|
|
||||||
```typescript
|
|
||||||
// Group by color for visualization
|
|
||||||
const colorGroups = filaments.reduce((groups, f) => {
|
|
||||||
const color = f.color.name;
|
|
||||||
groups[color] = groups[color] || [];
|
|
||||||
groups[color].push(f);
|
|
||||||
return groups;
|
|
||||||
}, {});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Usage Tracking**
|
|
||||||
```typescript
|
|
||||||
// Find most used filaments
|
|
||||||
const mostUsed = filaments
|
|
||||||
.filter(f => f.lastUsed)
|
|
||||||
.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed))
|
|
||||||
.slice(0, 10);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Strategy
|
|
||||||
|
|
||||||
### Phase 1: Add New Fields (Non-breaking)
|
|
||||||
```javascript
|
|
||||||
// Update Lambda to handle both old and new structure
|
|
||||||
const migrateFilament = (old) => ({
|
|
||||||
...old,
|
|
||||||
material: {
|
|
||||||
base: old.tip || 'PLA',
|
|
||||||
modifier: old.finish !== 'Basic' ? old.finish : undefined
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
name: old.boja
|
|
||||||
},
|
|
||||||
weight: {
|
|
||||||
value: 1000, // Default 1kg
|
|
||||||
unit: 'g'
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
total: parseInt(old.kolicina) || 1,
|
|
||||||
available: old.otvoreno ? 0 : 1,
|
|
||||||
inUse: 0,
|
|
||||||
locations: {
|
|
||||||
vacuum: old.vakum ? 1 : 0,
|
|
||||||
opened: old.otvoreno ? 1 : 0,
|
|
||||||
printer: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
condition: {
|
|
||||||
isRefill: old.refill === 'Da',
|
|
||||||
storageCondition: old.vakum ? 'vacuum' : (old.otvoreno ? 'opened' : 'sealed')
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Update UI Components
|
|
||||||
- Create new filter components for material type
|
|
||||||
- Add inventory status indicators
|
|
||||||
- Color preview badges
|
|
||||||
- Storage condition icons
|
|
||||||
|
|
||||||
### Phase 3: Enhanced Features
|
|
||||||
1. **Barcode/QR Integration**: Generate QR codes for each spool
|
|
||||||
2. **Usage History**: Track which prints used which filament
|
|
||||||
3. **Alerts**: Low stock, expiry warnings
|
|
||||||
4. **Analytics**: Cost per print, filament usage trends
|
|
||||||
|
|
||||||
## DynamoDB Optimization
|
|
||||||
|
|
||||||
### Current Indexes
|
|
||||||
- brand-index
|
|
||||||
- tip-index
|
|
||||||
- status-index
|
|
||||||
|
|
||||||
### Proposed Indexes
|
|
||||||
```terraform
|
|
||||||
global_secondary_index {
|
|
||||||
name = "material-color-index"
|
|
||||||
hash_key = "material.base"
|
|
||||||
range_key = "color.name"
|
|
||||||
}
|
|
||||||
|
|
||||||
global_secondary_index {
|
|
||||||
name = "inventory-status-index"
|
|
||||||
hash_key = "condition.storageCondition"
|
|
||||||
range_key = "inventory.available"
|
|
||||||
}
|
|
||||||
|
|
||||||
global_secondary_index {
|
|
||||||
name = "brand-type-index"
|
|
||||||
hash_key = "brand"
|
|
||||||
range_key = "material.base"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Queries
|
|
||||||
|
|
||||||
### Find all available green filaments
|
|
||||||
```javascript
|
|
||||||
const greenFilaments = await dynamodb.query({
|
|
||||||
IndexName: 'material-color-index',
|
|
||||||
FilterExpression: 'contains(color.name, :green) AND inventory.available > :zero',
|
|
||||||
ExpressionAttributeValues: {
|
|
||||||
':green': 'Green',
|
|
||||||
':zero': 0
|
|
||||||
}
|
|
||||||
}).promise();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get inventory summary
|
|
||||||
```javascript
|
|
||||||
const summary = await dynamodb.scan({
|
|
||||||
TableName: TABLE_NAME,
|
|
||||||
ProjectionExpression: 'brand, material.base, inventory'
|
|
||||||
}).promise();
|
|
||||||
|
|
||||||
const report = summary.Items.reduce((acc, item) => {
|
|
||||||
const key = `${item.brand}-${item.material.base}`;
|
|
||||||
acc[key] = (acc[key] || 0) + item.inventory.total;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
```
|
|
||||||
|
|
||||||
## UI Improvements
|
|
||||||
|
|
||||||
### 1. **Visual Inventory Status**
|
|
||||||
```tsx
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{filament.inventory.locations.vacuum > 0 && (
|
|
||||||
<Badge icon="vacuum" count={filament.inventory.locations.vacuum} />
|
|
||||||
)}
|
|
||||||
{filament.inventory.locations.opened > 0 && (
|
|
||||||
<Badge icon="box-open" count={filament.inventory.locations.opened} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Color Swatches**
|
|
||||||
```tsx
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-full border-2"
|
|
||||||
style={{ backgroundColor: filament.color.hex || getColorFromName(filament.color.name) }}
|
|
||||||
title={filament.color.name}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Smart Filters**
|
|
||||||
- Quick filters: "Ready to use", "Low stock", "Refills only"
|
|
||||||
- Material groups: "Standard PLA", "Engineering", "Specialty"
|
|
||||||
- Storage status: "Vacuum sealed", "Open spools", "In printer"
|
|
||||||
|
|
||||||
Would you like me to implement this improved structure?
|
|
||||||
@@ -3,4 +3,22 @@ import '@testing-library/jest-dom'
|
|||||||
// Add TextEncoder/TextDecoder globals for Node.js environment
|
// Add TextEncoder/TextDecoder globals for Node.js environment
|
||||||
const { TextEncoder, TextDecoder } = require('util');
|
const { TextEncoder, TextDecoder } = require('util');
|
||||||
global.TextEncoder = TextEncoder;
|
global.TextEncoder = TextEncoder;
|
||||||
global.TextDecoder = TextDecoder;
|
global.TextDecoder = TextDecoder;
|
||||||
|
|
||||||
|
// Mock axios globally
|
||||||
|
jest.mock('axios', () => ({
|
||||||
|
create: jest.fn(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
interceptors: {
|
||||||
|
request: {
|
||||||
|
use: jest.fn()
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
use: jest.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
-- Add 1 refill and 1 spulna for each color as PLA Basic filaments
|
|
||||||
-- Run this with: psql $DATABASE_URL -f scripts/add-basic-refills.sql
|
|
||||||
|
|
||||||
-- First show what colors we have
|
|
||||||
SELECT name, hex FROM colors ORDER BY name;
|
|
||||||
|
|
||||||
-- Insert PLA Basic filaments with 1 refill and 1 spulna for each color that doesn't already have one
|
|
||||||
INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena)
|
|
||||||
SELECT
|
|
||||||
'PLA' as tip,
|
|
||||||
'Basic' as finish,
|
|
||||||
c.name as boja,
|
|
||||||
c.hex as boja_hex,
|
|
||||||
1 as refill,
|
|
||||||
1 as spulna,
|
|
||||||
2 as kolicina, -- 1 refill + 1 spulna
|
|
||||||
'3999' as cena
|
|
||||||
FROM colors c
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM filaments f
|
|
||||||
WHERE f.tip = 'PLA'
|
|
||||||
AND f.finish = 'Basic'
|
|
||||||
AND f.boja = c.name
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Update any existing PLA Basic filaments to have 1 refill and 1 spulna
|
|
||||||
UPDATE filaments
|
|
||||||
SET refill = 1,
|
|
||||||
spulna = 1,
|
|
||||||
kolicina = 2 -- Update quantity to reflect 1 refill + 1 spulna
|
|
||||||
WHERE tip = 'PLA'
|
|
||||||
AND finish = 'Basic'
|
|
||||||
AND (refill = 0 OR spulna = 0);
|
|
||||||
|
|
||||||
-- Show summary
|
|
||||||
SELECT
|
|
||||||
'Total PLA Basic filaments with refills and spulna' as description,
|
|
||||||
COUNT(*) as count
|
|
||||||
FROM filaments
|
|
||||||
WHERE tip = 'PLA'
|
|
||||||
AND finish = 'Basic'
|
|
||||||
AND refill = 1
|
|
||||||
AND spulna = 1;
|
|
||||||
|
|
||||||
-- Show all PLA Basic filaments
|
|
||||||
SELECT
|
|
||||||
boja as color,
|
|
||||||
refill,
|
|
||||||
spulna,
|
|
||||||
kolicina as quantity,
|
|
||||||
cena as price
|
|
||||||
FROM filaments
|
|
||||||
WHERE tip = 'PLA'
|
|
||||||
AND finish = 'Basic'
|
|
||||||
ORDER BY boja;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "Running sale fields migration..."
|
|
||||||
|
|
||||||
# Get RDS endpoint from AWS
|
|
||||||
RDS_ENDPOINT=$(aws rds describe-db-instances --region eu-central-1 --db-instance-identifier filamenteka --query 'DBInstances[0].Endpoint.Address' --output text)
|
|
||||||
|
|
||||||
# Get database credentials from Secrets Manager
|
|
||||||
DB_CREDS=$(aws secretsmanager get-secret-value --region eu-central-1 --secret-id filamenteka-db-credentials --query 'SecretString' --output text)
|
|
||||||
DB_USER=$(echo $DB_CREDS | jq -r '.username')
|
|
||||||
DB_PASS=$(echo $DB_CREDS | jq -r '.password')
|
|
||||||
DB_NAME=$(echo $DB_CREDS | jq -r '.database')
|
|
||||||
|
|
||||||
# Run the migration
|
|
||||||
PGPASSWORD="$DB_PASS" psql -h $RDS_ENDPOINT -U $DB_USER -d $DB_NAME -f database/migrations/014_add_sale_fields.sql
|
|
||||||
|
|
||||||
echo "Migration completed!"
|
|
||||||
Reference in New Issue
Block a user