feat: add allowed Linear API operations to MCP config

This commit is contained in:
Do Siki
2025-09-05 03:10:17 +02:00
parent 7e7d3fb1cc
commit b6365543ef
36 changed files with 14648 additions and 1 deletions
+11 -1
View File
@@ -25,7 +25,17 @@
}, },
"linear": { "linear": {
"command": "npx", "command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] "args": [
"-y",
"mcp-remote",
"https://mcp.linear.app/sse"
],
"alwaysAllow": [
"list_issues",
"get_issue",
"update_issue",
"create_issue"
]
} }
} }
} }
+101
View File
@@ -0,0 +1,101 @@
# mozdIT Weboldal - Fejlesztési TODO Lista
## Projekt Áttekintés
Next.js 14 alapú weboldal a mozdIT Bt. számára, Dokploy-on hostolva.
## ⚠️ Projekt Management
**Elsődleges forrás**: Linear (ZEE-28, ZEE-29, ZEE-30, stb.)
**Lokális másolat**: Ez a fájl csak referencia, a Linear az authoritative source
## ✅ Befejezett (Linear szerint)
| Linear Ticket | Feladat | Státusz |
|---------------|---------|---------|
| ZEE-28 | Repo & Next.js bootstrap - befejezett, dev szerver fut localhost:3000-n | ✅ |
| ZEE-29 | Tailwind + alap layout | ✅ |
| ZEE-30 | Kezdőlap (Hero + USP + Webmail CTA) | ✅ |
| - | Unit teszt infrastruktúra beállítása (Jest + React Testing Library) | ✅ |
| - | API endpoint tesztek írása (/api/health) | ✅ |
| - | Component tesztek írása (Header, Footer) | ✅ |
| - | Teszt hibák javítása (Jest setup, duplicate elements, type casting) | ✅ |
| ZEE-34 | Basic API endpoints (/api/health) hozzáadása | ✅ |
## 🔄 Folyamatban (Linear szerint)
| Linear Ticket | Feladat | Státusz |
|---------------|---------|---------|
| ZEE-35 | Dokploy staging environment beállítása | 🔄 |
| TBC-1 | Site config migrálás MongoDB-ba | 🔄 |
| TBC-2 | Winston logger + Loki integráció | 🔄 |
| TBC-3 | Logging middleware megvalósítása | 🔄 |
| TBC-4 | Grafana dashboard konfiguráció | 🔄 |
## 📋 Tervezett
| Linear Ticket | Feladat | Státusz |
|---------------|---------|---------|
| ZEE-33 | Kapcsolat űrlap + API stub | 📋 |
| ZEE-32 | Szolgáltatások oldal | 📋 |
| ZEE-31 | Rólunk oldal | 📋 |
| TBC-5 | SEO optimalizálás | 📋 |
| TBC-6 | Performance optimalizálás | 📋 |
| TBC-7 | Reszponzív design finomítása | 📋 |
| TBC-8 | Analytics integráció | 📋 |
## Technikai Stack
- **Frontend**: Next.js 14, TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes
- **Database**: MongoDB
- **Logging**: Winston + Loki
- **Monitoring**: Grafana
- **Deployment**: Dokploy
- **Testing**: Jest, React Testing Library
- **Project Management**: Linear (elsődleges)
## Fejlesztési Parancsok
```bash
# Fejlesztői szerver indítása
cd proto && npm run dev
# Tesztek futtatása
cd proto && npm test
# Build készítése
cd proto && npm run build
```
## Fontos Megjegyzés
A Linear az authoritative project management rendszer. Ez a fájl csak lokális referencia, mindig ellenőrizd a Linear-t az aktuális státuszért és priorításokért.
## Frissítési Napló
- **2025-09-05**: TODO.md fájl létrehozása, Linear integráció megjegyzésekkel
## Szinkronizálás Utmutató
A TODO.md és Linear között párhuzamos vezetéshez használd a `linear-sync.js` scriptet:
```bash
# API kulcs hozzáadása a .env fájlhoz (Linear settings -> API)
# Szerkesd a .env fájlt és add hozzá:
# LINEAR_API_KEY=lin_api_your_actual_key_here
# Szinkronizálás futtatása
node linear-sync.js
# vagy
./linear-sync.js
```
A script:
- Megkeresi a TODO.md-ben hiányzó Linear ticket-eket
- Létrehozza ezeket a Linear-ben
- Frissíti a Linear ticket státuszokat a TODO alapján
- Visszairja a Linear ticket számokat a TODO.md-be
### Hiányzó ticketek a jelenlegi TODO.md-ben:
- TBC-1: Site config migrálás MongoDB-ba
- TBC-2: Winston logger + Loki integráció
- TBC-3: Logging middleware megvalósítása
- TBC-4: Grafana dashboard konfiguráció
- TBC-5: SEO optimalizálás
- TBC-6: Performance optimalizálás
- TBC-7: Reszponzív design finomítása
- TBC-8: Analytics integráció
Script futtatás után ezek Linear ticket számmal lesznek helyettesítve.
Executable
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env node
/**
* Linear-TODO Sync Script
*
* Synchronizes TODO.md with Linear issues for the mozdIT website project
* Run this script to:
* - Create Linear tickets for TODO tasks without tickets
* - Update Linear ticket statuses based on TODO statuses
* - Update TODO statuses based on Linear ticket statuses
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
// Load environment variables from .env file
function loadDotEnv() {
try {
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=');
if (key && valueParts.length > 0) {
const value = valueParts.join('=').trim();
process.env[key] = value;
}
}
}
console.log('✅ .env fájl betöltve');
} else {
console.log('⚠️ .env fájl hiányzik - szükség lesz rá a LINEAR_API_KEY-hez');
}
} catch (error) {
console.warn('⚠️ .env fájl betöltési hiba:', error.message);
}
}
// Command line argument parsing
function parseArgs() {
const args = process.argv.slice(2);
const options = {
dryRun: false,
verbose: false
};
for (const arg of args) {
switch (arg) {
case '--dry-run':
options.dryRun = true;
break;
case '--verbose':
options.verbose = true;
break;
case '--help':
console.log(`
🔄 Linear-TODO Sync Tool
Használat:
node linear-sync.js [opciók]
Opciók:
--dry-run Teszt üzemmód - nem hajt végre valódi változtatásokat
--verbose Részletes kimenet
--help Ez a súgó
Példa:
node linear-sync.js --dry-run --verbose
`);
process.exit(0);
break;
}
}
return options;
}
// Rate limiting helper
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Load .env on startup
loadDotEnv();
// Configuration
const LINEAR_API_ENDPOINT = 'https://linear.app/graphql';
const TEAM_ID = 'cf285407-a26b-434c-bb99-19676385ef67'; // Zeener team
const PROJECT_ID = '54559e3b-9005-4dfa-b7a9-1415cd4bc453'; // Website Development project
const TODO_FILE = process.env.TODO_FILE || 'TODO.md';
// Global options
let OPTIONS = parseArgs();
/**
* Linear GraphQL client
*/
class LinearClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.lastRequestTime = 0;
this.minDelay = 1000; // 1 second minimum delay between requests
}
async request(query, variables = {}) {
// Rate limiting
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < this.minDelay) {
const delayTime = this.minDelay - timeSinceLastRequest;
if (OPTIONS.verbose) {
console.log(`⏳ Rate limiting: waiting ${delayTime}ms`);
}
await delay(delayTime);
}
this.lastRequestTime = Date.now();
if (OPTIONS.dryRun) {
console.log(`🔍 DRY RUN: Would execute GraphQL query:`, query.substring(0, 100) + '...');
return {}; // Mock response
}
return new Promise((resolve, reject) => {
const data = JSON.stringify({ query, variables });
const options = {
hostname: 'linear.app',
path: '/graphql',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.apiKey,
'Content-Length': Buffer.byteLength(data)
}
};
if (OPTIONS.verbose) {
console.log(`🔗 API hívás: ${options.method} ${options.hostname}${options.path}`);
console.log(`📝 Query: ${query.substring(0, 80)}...`);
}
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(body);
if (response.errors) {
console.error('🔍 GraphQL errors:', response.errors);
reject(new Error(`GraphQL Error: ${response.errors.map(e => e.message).join(', ')}`));
} else {
if (OPTIONS.verbose) {
console.log('✅ API válasz kapott');
}
resolve(response.data);
}
} catch (error) {
// If it's HTML error page
if (body.includes('<!doctype')) {
const preview = body.slice(0, 200).replace(/<[^>]*>/g, ''); // Remove HTML tags
console.log('🔍 HTML válasz előzetes:', preview + '...');
// Mask API key in error message
const maskedKey = this.apiKey ?
this.apiKey.replace(/(.{8}).*(.{4})/, '$1****$2') : '[NO_KEY]';
reject(new Error(`Linear API HTML válasz visszaadott JSON helyett. Ellenőrizd az API kulcsot (${maskedKey}) és endpoint-ot.`));
} else {
reject(error);
}
}
});
});
req.on('error', (error) => {
console.error('🔍 Hálózati hiba:', error.message);
reject(error);
});
req.write(data);
req.end();
});
}
async getIssues() {
const query = `
query GetIssues($teamId: ID!) {
issues(filter: { team: { id: { eq: $teamId } } }) {
nodes {
id
identifier
title
description
status
project {
id
name
}
}
}
}
`;
const result = await this.request(query, { teamId: TEAM_ID });
return result.issues.nodes;
}
async createIssue(title, description) {
const query = `
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
issue {
id
identifier
title
url
}
}
}
`;
const input = {
title,
description,
teamId: TEAM_ID,
projectId: PROJECT_ID
};
const result = await this.request(query, { input });
return result.issueCreate.issue;
}
async updateIssueStatus(issueId, status) {
const query = `
mutation UpdateIssueStatus($input: IssueUpdateInput!) {
issueUpdate(input: $input) {
issue {
id
status
}
}
}
`;
const input = {
id: issueId,
status
};
const result = await this.request(query, { input });
return result.issueUpdate.issue;
}
}
/**
* Parse TODO.md content
*/
function parseTodoFile(content) {
const lines = content.split('\n');
const todos = { completed: [], inProgress: [], planned: [] };
let currentSection = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('## ✅ Befejezett')) {
currentSection = 'completed';
} else if (line.startsWith('## 🔄 Folyamatban')) {
currentSection = 'inProgress';
} else if (line.startsWith('## 📋 Tervezett')) {
currentSection = 'planned';
} else if (currentSection && line.includes('|')) {
// Parse table rows
const parts = line.split('|').map(p => p.trim()).filter(p => p);
if (parts.length >= 3 && parts[0] !== 'Linear Ticket') {
const ticket = parts[0] === '-' || parts[0] === 'TBD' ? null : parts[0];
const task = parts[1];
const status = parts[2];
todos[currentSection].push({
ticket,
task,
status,
isMissingTicket: !ticket || ticket.startsWith('TBC-')
});
}
}
}
return todos;
}
/**
* Update TODO.md with new ticket numbers
*/
function updateTodoFile(content, ticketMappings) {
let updatedContent = content;
ticketMappings.forEach(mapping => {
const placeholder = mapping.placeholder;
const newTicket = mapping.ticket;
updatedContent = updatedContent.replace(placeholder, newTicket);
});
return updatedContent;
}
/**
* Main sync function
*/
async function syncTodoLinear() {
const modePrefix = OPTIONS.dryRun ? '(DRY RUN) ' : '';
console.log(`${modePrefix}🔄 Synchronizing TODO.md with Linear...`);
// Check for API key
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey || apiKey === 'your_linear_api_key_here') {
console.error('❌ ERROR: LINEAR_API_KEY is required in .env file');
console.log(' Add your Linear API key to .env file:');
console.log(' LINEAR_API_KEY=lin_api_your_actual_key_here');
console.log(' Get your API key from: https://linear.app/settings/api');
console.log(' Optional: Use --dry-run --verbose for safe testing');
return;
}
const client = new LinearClient(apiKey);
// Quick API validation
try {
if (OPTIONS.verbose) {
console.log(`${modePrefix}🔐 Validating API connection...`);
}
await client.request('query { viewer { id name } }');
console.log(`${modePrefix}✅ API connection validated`);
} catch (validationError) {
console.error('❌ API validation failed:', validationError.message);
console.error(' Please check your LINEAR_API_KEY in .env file');
console.error(' Visit: https://linear.app/settings/api');
return;
}
try {
console.log(`${modePrefix} Reading TODO.md...`);
const content = fs.readFileSync(TODO_FILE, 'utf8');
const todos = parseTodoFile(content);
if (OPTIONS.verbose) {
console.log(`${modePrefix}📊 Found todos:`, {
completed: todos.completed.length,
inProgress: todos.inProgress.length,
planned: todos.planned.length
});
}
console.log(`${modePrefix}🚀 Fetching existing Linear issues...`);
const existingIssues = await client.getIssues();
const existingTitles = existingIssues.map(i => i.title);
if (OPTIONS.verbose) {
console.log(`${modePrefix}📋 Found ${existingIssues.length} Linear issues`);
}
// Find tasks missing Linear tickets
const missingTickets = [
...todos.inProgress.filter(t => t.isMissingTicket),
...todos.planned.filter(t => t.isMissingTicket)
];
console.log(`${modePrefix}📋 Found ${missingTickets.length} tasks without Linear tickets`);
const ticketMappings = [];
for (const item of missingTickets) {
if (!existingTitles.includes(item.task)) {
console.log(`${modePrefix}🔧 Creating Linear issue: ${item.task}`);
try {
const issue = await client.createIssue(
item.task,
`## ${item.task}\n\n### Acceptance Criteria\n\n- Task to be completed\n\n### Implementation Notes\n\n- Details to be added`
);
console.log(`${modePrefix}✅ Created issue: ${issue.identifier} - ${issue.url}`);
// Replace TBC-x with actual ticket
const placeholder = item.ticket || item.task;
ticketMappings.push({
placeholder: placeholder,
ticket: issue.identifier,
task: item.task
});
} catch (error) {
console.error(`❌ Failed to create issue for: ${item.task}`, error.message);
}
} else {
if (OPTIONS.verbose) {
console.log(`${modePrefix}⏭️ Issue already exists for: ${item.task}`);
}
}
}
// Update Linear status for completed tasks
console.log(`${modePrefix}📋 Updating Linear statuses for completed tasks...`);
let statusUpdateCount = 0;
for (const issue of existingIssues) {
if (todos.completed.some(t => t.task === issue.title) && issue.status !== 'Done') {
console.log(`${modePrefix}🔧 Updating status to Done: ${issue.identifier} - ${issue.title}`);
try {
await client.updateIssueStatus(issue.id, 'Done');
statusUpdateCount++;
if (OPTIONS.verbose) {
console.log(`${modePrefix}✅ Updated: ${issue.identifier}`);
}
} catch (error) {
console.error(`❌ Failed to update: ${issue.identifier}`, error.message);
}
}
}
console.log(`${modePrefix}📊 Updated ${statusUpdateCount} Linear issues`);
// Update TODO.md with new ticket numbers
if (ticketMappings.length > 0) {
console.log(`${modePrefix}📋 Updating TODO.md with ${ticketMappings.length} new ticket numbers...`);
if (OPTIONS.dryRun) {
console.log(`${modePrefix}📋 DRY RUN: Would update TODO.md with mappings:`,
ticketMappings.map(m => `${m.placeholder}${m.ticket}`).join(', ')
);
} else {
// Create backup
const backupPath = `${TODO_FILE}.backup.${Date.now()}`;
fs.copyFileSync(TODO_FILE, backupPath);
console.log(`${modePrefix}💾 Backup created: ${backupPath}`);
const updatedContent = updateTodoFile(content, ticketMappings);
fs.writeFileSync(TODO_FILE, updatedContent, 'utf8');
console.log(`${modePrefix}✅ TODO.md updated successfully`);
}
}
const actionSummary = OPTIONS.dryRun ?
`(DRY RUN: No changes made)` :
`(Created ${ticketMappings.length} issues, updated ${statusUpdateCount} statuses)`;
console.log(`${modePrefix}🎉 Synchronization complete! ${actionSummary}`);
} catch (error) {
console.error('❌ Synchronization failed:', error.message);
if (error.response && error.response.data) {
console.error('Response:', error.response.data);
}
if (error.message.includes('GraphQL') && OPTIONS.verbose) {
console.error('🔍 Full error details:', error);
}
process.exit(1);
}
}
// Run the sync if this script is executed directly
if (require.main === module) {
syncTodoLinear().catch(console.error);
}
module.exports = { syncTodoLinear };
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+25
View File
@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
+25
View File
@@ -0,0 +1,25 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
// Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths)
'^@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'jest-environment-jsdom',
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/index.ts',
'!src/**/*.d.ts',
],
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)
+302
View File
@@ -0,0 +1,302 @@
// No need to require jest - it's available globally in Jest test files
// JEST MOCKS - must be set up before importing any modules that use them
// Mock MongoDB - fix MongoClient to work properly
const mockAdmin = {
ping: jest.fn().mockResolvedValue({ ok: 1 })
}
const mockDb = {
collection: jest.fn().mockReturnValue({
findOne: jest.fn().mockResolvedValue(null),
replaceOne: jest.fn().mockResolvedValue({ acknowledged: true }),
find: jest.fn(() => ({
toArray: jest.fn().mockResolvedValue([])
})),
insertOne: jest.fn().mockResolvedValue({ acknowledged: true, insertedId: 'test-id' }),
insertMany: jest.fn().mockResolvedValue({ acknowledged: true, insertedIds: { 0: 'test-id' } }),
updateOne: jest.fn().mockResolvedValue({ acknowledged: true, matchedCount: 1, modifiedCount: 1 }),
updateMany: jest.fn().mockResolvedValue({ acknowledged: true, matchedCount: 2, modifiedCount: 2 }),
deleteOne: jest.fn().mockResolvedValue({ acknowledged: true, deletedCount: 1 }),
deleteMany: jest.fn().mockResolvedValue({ acknowledged: true, deletedCount: 2 }),
bulkWrite: jest.fn().mockResolvedValue({ acknowledged: true, insertedCount: 0, matchedCount: 0, modifiedCount: 0, deletedCount: 0, upsertedCount: 0, upsertedIds: {} })
}),
admin: jest.fn().mockReturnValue(mockAdmin)
}
const mockClient = {
connect: jest.fn().mockResolvedValue({}),
close: jest.fn().mockResolvedValue(undefined),
db: jest.fn().mockImplementation((dbName) => mockDb)
}
jest.mock('mongodb', () => ({
MongoClient: jest.fn().mockImplementation(() => mockClient)
}))
// Mock environment variables for tests
process.env.MONGODB_URI = 'mongodb://localhost:27017/test'
process.env.MONGODB_DB = 'test'
// Mock Winston logger - simplified for default export
jest.mock('winston', () => ({
transports: {
Console: jest.fn(),
},
createLogger: jest.fn(() => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
child: jest.fn(() => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
isLevelEnabled: jest.fn().mockReturnValue(true),
levels: { error: 0, warn: 1, info: 2, debug: 3 }
})),
isLevelEnabled: jest.fn().mockReturnValue(true),
levels: { error: 0, warn: 1, info: 2, debug: 3 }
})),
format: {
timestamp: jest.fn(() => ({})),
errors: jest.fn(() => ({})),
json: jest.fn(() => ({})),
colorize: jest.fn(() => ({})),
simple: jest.fn(() => ({})),
combine: jest.fn((...args) => args[args.length - 1] || {}),
printf: jest.fn(() => ({}))
}
}))
// Mock logger module to use the mocked winston
jest.mock('./src/lib/logger', () => {
const mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
child: jest.fn(() => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
log: jest.fn()
}))
}
return {
__esModule: true,
default: mockLogger,
logger: mockLogger,
createComponentLogger: jest.fn(() => mockLogger.child()),
requestLogger: mockLogger.child(),
apiLogger: mockLogger.child(),
dbLogger: mockLogger.child(),
authLogger: mockLogger.child(),
errorLogger: mockLogger.child(),
generateRequestId: jest.fn(() => 'test-request-id'),
withTiming: jest.fn(async (operation, fn) => fn())
}
})
// Mock Next.js Response objects
global.Response = class Response {
constructor(body, init) {
this.body = body
this.status = init?.status || 200
this.statusText = init?.statusText || ''
this.headers = new Map()
if (init?.headers) {
Object.entries(init.headers).forEach(([key, value]) => {
this.headers.set(key.toLowerCase(), value)
})
}
}
json() {
return Promise.resolve(JSON.parse(this.body || '{}'))
}
headers = {
get: (key) => this.headers.get(key.toLowerCase())
}
}
global.NextResponse = {
json: (data, init) => {
return new Response(JSON.stringify(data), {
status: init?.status || 200,
headers: init?.headers || {}
})
}
}
// Mock Next.js NextResponse properly
jest.mock('next/server', () => ({
NextResponse: {
json: jest.fn((data, init) => {
return new Response(JSON.stringify(data), {
status: init?.status || 200,
headers: init?.headers || {}
})
})
},
NextRequest: jest.fn()
}))
// Ensure process.env is available in tests
if (!global.process.env) {
global.process.env = {}
}
// Logger will be mocked by the winston mock above
// Mock Loki transport
jest.mock('winston-loki', () => jest.fn(() => ({
log: jest.fn(),
close: jest.fn()
})))
// Mock Next.js router
jest.mock('next/router', () => ({
useRouter: () => ({
route: '/',
pathname: '/',
query: {},
asPath: '/',
push: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
back: jest.fn(),
prefetch: jest.fn(),
beforePopState: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
}),
}))
// Mock Next.js navigation
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
refresh: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
prefetch: jest.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/',
}))
// Mock Response constructor for Next.js API routes
global.Response = class MockResponse {
constructor(body, options = {}) {
this.body = body
this.status = options.status || 200
this.statusText = options.statusText || 'OK'
this.headers = new Map([
['content-type', options.headers?.['content-type'] || 'application/json'],
...Object.entries(options.headers || {})
])
// Next.js Response.json() method
this.json = jest.fn(() => {
try {
return JSON.parse(body || '{}')
} catch {
return body
}
})
this.text = jest.fn(() => Promise.resolve(body || ''))
this.arrayBuffer = jest.fn(() => Promise.resolve(new ArrayBuffer(0)))
}
get(name) {
return this.headers.get(name.toLowerCase())
}
set(name, value) {
this.headers.set(name.toLowerCase(), value)
}
clone() {
return { ...this }
}
}
// Mock Request constructor for Next.js API routes
global.Request = class MockRequest {
constructor(url, options = {}) {
this.url = url
this.method = options.method || 'GET'
this.headers = new Map([
['content-type', 'application/json'],
...Object.entries(options.headers || {})
])
this.body = options.body || null
}
json() {
return Promise.resolve(this.body ? JSON.parse(this.body) : {})
}
text() {
return Promise.resolve(this.body || '')
}
get(name) {
return this.headers.get(name.toLowerCase())
}
}
// Mock NextResponse for API routes
global.NextResponse = {
json: jest.fn((data, options = {}) => {
return new global.Response(JSON.stringify(data), {
status: options.status || 200,
headers: {
'content-type': 'application/json',
...options.headers
}
})
}),
redirect: jest.fn((url, status = 302) => ({
url,
status,
headers: new Map([['location', url]])
})),
rewrite: jest.fn((url) => ({
url,
status: 200
}))
}
// Set up environment variables for tests
process.env.MONGODB_URI = 'mongodb://localhost:27017/test'
process.env.MONGODB_DB = 'test'
process.env.LOKI_HOST = 'http://loki:3100'
process.env.NODE_ENV = 'test'
process.env.NEXT_PUBLIC_CONTACT_EMAIL = 'info@mozdit.hu'
process.env.NEXT_PUBLIC_SITE_URL = 'https://localhost:3000'
process.env.npm_package_version = '1.0.0'
process.env.PACKAGE_VERSION = '1.0.0'
// Set up cleanup for consistent testing
afterEach(() => {
jest.clearAllMocks()
})
// Import React Testing Library DOM (using require for Jest setup files)
require('@testing-library/jest-dom')
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+11614
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "proto",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.2",
"mongodb": "^6.5",
"mongoose": "^8.2"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"@eslint/eslintrc": "^3",
"jest": "^29.7",
"jest-environment-jsdom": "^29.7",
"@testing-library/react": "^16.0",
"@testing-library/jest-dom": "^6.5",
"@testing-library/user-event": "^14.5",
"@types/jest": "^29.5",
"winston": "^3.11",
"winston-loki": "^6.0",
"@types/winston": "^2.4"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+85
View File
@@ -0,0 +1,85 @@
import { GET, HEAD } from './route'
import { NextRequest } from 'next/server'
// Mock the process object for tests
const mockProcess = {
uptime: jest.fn().mockReturnValue(1234),
env: {
npm_package_version: '1.0.0',
NODE_ENV: 'test',
},
}
const originalProcess = global.process
beforeEach(() => {
global.process = { ...originalProcess, ...mockProcess } as any
})
afterEach(() => {
global.process = originalProcess
})
describe('/api/health', () => {
describe('GET request', () => {
it('should return successful health status with required data', async () => {
const response = await GET()
// Check response status
expect(response.status).toBe(200)
// Get JSON data
const data = await response.json()
// Verify required fields
expect(data).toHaveProperty('status', 'ok')
expect(data).toHaveProperty('timestamp')
expect(data).toHaveProperty('uptime')
expect(data).toHaveProperty('version')
expect(data).toHaveProperty('environment')
// Verify timestamp is a valid ISO string
expect(() => new Date(data.timestamp)).not.toThrow()
expect(data.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
// Verify uptime matches mock
expect(data.uptime).toBe(1234)
// Verify version
expect(data.version).toBe('1.0.0')
})
it('should include correct Cache-Control headers', async () => {
const response = await GET()
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate')
expect(response.headers.get('Pragma')).toBe('no-cache')
expect(response.headers.get('Expires')).toBe('0')
})
it('should handle errors gracefully', async () => {
// Temporarily break process.env to simulate error
global.process.env = undefined as any
const response = await GET()
expect(response.status).toBe(503)
const data = await response.json()
expect(data.status).toBe('error')
expect(data).toHaveProperty('timestamp')
expect(data).toHaveProperty('message', 'Health check failed')
// Restore process.env
global.process.env = originalProcess.env
})
})
describe('HEAD request', () => {
it('should return 200 status without body', async () => {
const response = await HEAD()
expect(response.status).toBe(200)
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate')
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
export async function GET() {
try {
// Basic health check
const healthData = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development',
};
return NextResponse.json(healthData, {
status: 200,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Health check error:', error);
return NextResponse.json(
{
status: 'error',
timestamp: new Date().toISOString(),
message: 'Health check failed',
},
{ status: 503 }
);
}
}
// Also support HEAD requests for lighter health checks
export async function HEAD() {
return new Response(null, {
status: 200,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "../components/Header";
import Footer from "../components/Footer";
import { siteConfig } from "../config/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: `${siteConfig.general.name} | ${siteConfig.general.description}`,
description: siteConfig.general.description,
authors: [{ name: siteConfig.general.name }],
keywords: ["web hosting", "email szolgáltatás", "DNS adminisztráció", "IT szolgáltatás", "mozdIT"],
openGraph: {
title: siteConfig.general.name,
description: siteConfig.general.description,
url: siteConfig.general.url,
siteName: siteConfig.general.name,
images: [
{
url: siteConfig.general.ogImage,
width: 1200,
height: 630,
alt: siteConfig.general.name,
},
],
locale: siteConfig.general.locale,
type: "website",
},
twitter: {
card: "summary_large_image",
title: siteConfig.general.name,
description: siteConfig.general.description,
images: [siteConfig.general.ogImage],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="hu">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
>
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { siteConfig } from '@/config/site'
export default function Home() {
return (
<div className="space-y-16">
{/* Hero Section */}
<section className="bg-gradient-to-r from-blue-50 to-indigo-50 py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
{siteConfig.hero.title}
</h1>
<p className="text-lg md:text-xl text-gray-600 max-w-4xl mx-auto mb-8 leading-relaxed">
{siteConfig.hero.description}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a
href={siteConfig.hero.cta.primary.href}
target={siteConfig.hero.cta.primary.external ? '_blank' : undefined}
rel={siteConfig.hero.cta.primary.external ? 'noopener noreferrer' : undefined}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-4 rounded-md transition-colors text-lg inline-flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10" />
</svg>
{siteConfig.hero.cta.primary.text}
</a>
{siteConfig.hero.cta.secondary && (
<a
href={siteConfig.hero.cta.secondary.href}
className="border-2 border-blue-600 text-blue-600 hover:bg-blue-50 font-medium px-8 py-4 rounded-md transition-colors text-lg"
>
{siteConfig.hero.cta.secondary.text}
</a>
)}
</div>
</div>
</section>
{/* USP Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 bg-gray-50">
<div className="text-center">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-8">
{siteConfig.about.title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{siteConfig.about.usps.map((usp) => (
<div key={usp.id} className="text-center">
<div className="w-16 h-16 bg-blue-100 group-hover:bg-blue-200 rounded-full flex items-center justify-center mx-auto mb-4 transition-colors">
<span className="text-2xl">{usp.icon}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{usp.title}</h3>
<p className="text-gray-600">{usp.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Services Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6">
{siteConfig.services.title}
</h2>
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
{siteConfig.services.subtitle}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{siteConfig.services.services.map((service) => (
<div key={service.id} className="group bg-white p-8 rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-all duration-300 hover:border-blue-200">
<div className="w-12 h-12 bg-blue-100 group-hover:bg-blue-200 rounded-lg flex items-center justify-center mb-4 transition-colors">
<span className="text-xl">{service.icon}</span>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3 group-hover:text-blue-600 transition-colors">{service.title}</h3>
<p className="text-gray-600 leading-relaxed">
{service.description}
</p>
<div className="mt-4">
<h4 className="text-sm font-semibold text-gray-700 mb-2">Szolgáltatás jellemzők:</h4>
<ul className="text-sm text-gray-600 space-y-1">
{service.features.map((feature, index) => (
<li key={index} className="flex items-start">
<span className="text-blue-500 mr-2"></span>
{feature}
</li>
))}
</ul>
</div>
<a href="/kapcsolat" className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium mt-4 transition-colors">
{service.ctaText}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
))}
</div>
</section>
{/* CTA Section */}
<section className="bg-gray-900 text-white py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold mb-4">
Kapcsolatfelvétel az első lépés
</h2>
<p className="text-xl text-gray-300 mb-8">
Mutassuk meg, hogyan segíthetünk Önnek megvalósítani címeit!
</p>
<a
href="/kapcsolat"
className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-3 rounded-md transition-colors"
>
Kapcsolatfelvétel
</a>
</div>
</section>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Footer from './Footer'
describe('Footer', () => {
it('should render company information', () => {
render(<Footer />)
expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument()
expect(screen.getByText('Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.')).toBeInTheDocument()
})
it('should render email and company details', () => {
render(<Footer />)
expect(screen.getByText('Email: info@mozdit.hu')).toBeInTheDocument()
expect(screen.getByText('Cég: mozdIT Bt.')).toBeInTheDocument()
expect(screen.getByText('Székhely: Budapest, Magyarország')).toBeInTheDocument()
})
it('should render navigation links', () => {
render(<Footer />)
expect(screen.getByText('Kezdőlap')).toBeInTheDocument()
expect(screen.getByText('Rólunk')).toBeInTheDocument()
expect(screen.getAllByText('Szolgáltatások')).toHaveLength(2) // Both in navigation and services section
expect(screen.getByText('Kapcsolat')).toBeInTheDocument()
})
it('should render service sections', () => {
render(<Footer />)
expect(screen.getByText('Web Hosting')).toBeInTheDocument()
expect(screen.getByText('Email Szolgáltatás')).toBeInTheDocument()
expect(screen.getByText('DNS Adminisztráció')).toBeInTheDocument()
expect(screen.getByText('Műszaki támogatás')).toBeInTheDocument()
})
it('should render copyright notice with dynamic year', () => {
render(<Footer />)
const currentYear = new Date().getFullYear()
expect(screen.getByText(`© ${currentYear} mozdIT Bt. Minden jog fenntartva.`)).toBeInTheDocument()
})
it('should render legal links', () => {
render(<Footer />)
expect(screen.getAllByText('Adatvédelmi tájékoztató')).toHaveLength(2) // Appears in both sections
expect(screen.getAllByText('Használati feltételek')).toHaveLength(2) // Appears in both sections
})
it('should render with proper grid layout', () => {
const { container } = render(<Footer />)
const gridContainer = container.querySelector('.grid.grid-cols-1.md\\:grid-cols-4')
expect(gridContainer).toBeInTheDocument()
// Check for responsive grid classes
expect(gridContainer).toHaveClass('grid-cols-1', 'md:grid-cols-4')
})
it('should render with proper semantic structure', () => {
const { container } = render(<Footer />)
// Should have a footer element
const footer = container.firstChild as HTMLElement
expect(footer?.tagName).toBe('FOOTER')
// Should have proper background and padding
expect(footer).toHaveClass('bg-gray-50', 'border-t')
})
})
+79
View File
@@ -0,0 +1,79 @@
import { siteConfig } from '@/config/site'
export default function Footer() {
return (
<footer className="bg-gray-50 border-t border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Company Info */}
<div className="md:col-span-2">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{siteConfig.general.name}
</h3>
<p className="text-gray-600 mb-4 max-w-md">
{siteConfig.general.description}
</p>
<div className="flex space-x-4">
<div className="text-sm text-gray-500">
<p>Email: {siteConfig.contact.email}</p>
<p>Cég: {siteConfig.general.name}</p>
<p>Székhely: {siteConfig.contact.address}</p>
</div>
</div>
</div>
{/* Navigation Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide mb-4">
Navigáció
</h3>
<ul className="space-y-2">
{siteConfig.navigation.footer.map((item) => (
<li key={item.href}>
<a href={item.href} className="text-gray-600 hover:text-blue-600 transition-colors">
{item.label}
</a>
</li>
))}
</ul>
</div>
{/* Services */}
<div>
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide mb-4">
Szolgáltatások
</h3>
<ul className="space-y-2">
{siteConfig.services.services.map((service) => (
<li key={service.id} className="text-gray-600">
{service.title}
</li>
))}
<li className="text-gray-600">Műszaki támogatás</li>
</ul>
</div>
</div>
{/* Bottom section */}
<div className="border-t border-gray-200 pt-8 mt-8">
<div className="flex flex-col sm:flex-row justify-between items-center">
<p className="text-gray-500 text-sm">
{siteConfig.footer.copyright}
</p>
<div className="flex space-x-4 mt-4 sm:mt-0">
{siteConfig.footer.links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-gray-500 hover:text-blue-600 text-sm transition-colors"
>
{link.label}
</a>
))}
</div>
</div>
</div>
</div>
</footer>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Header from './Header'
import userEvent from '@testing-library/user-event'
// Mock Next.js Link component
jest.mock('next/link', () => {
return ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
)
})
describe('Header', () => {
it('should render the company logo', () => {
render(<Header />)
expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument()
})
it('should render all navigation links in desktop menu', () => {
render(<Header />)
// Desktop menu should contain all links with specific structures
const desktopMenu = document.querySelector('.hidden.md\\:block')
expect(desktopMenu).toBeInTheDocument()
const navLinks = screen.getAllByText('Kezdőlap')
expect(navLinks.length).toBeGreaterThan(0)
expect(screen.getAllByText('Rólunk')).toHaveLength(2) // Both in desktop and mobile menus
expect(screen.getAllByText('Szolgáltatások')).toHaveLength(2) // Both in desktop and mobile menus
})
it('should render contact button with correct styling', () => {
render(<Header />)
const contactButtons = screen.getAllByText('Kapcsolat')
expect(contactButtons.length).toBeGreaterThan(0)
// Check if any contact button has the correct styling
const contactButton = contactButtons[0]
expect(contactButton).toBeInTheDocument()
// Check for blue background styling
const contactLink = contactButton.closest('a')
if (contactLink) {
expect(contactLink).toHaveClass('bg-blue-600')
}
})
it('should render hamburger menu button on mobile', () => {
render(<Header />)
// The hamburger menu button is hidden by default in desktop view
// We can test its presence even if not visible
const hamburgerButton = screen.getByRole('button')
expect(hamburgerButton).toBeInTheDocument()
})
it('should have proper accessibility attributes', () => {
render(<Header />)
const hamburgerButton = screen.getByRole('button')
expect(hamburgerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should render with proper semantic structure', () => {
const { container } = render(<Header />)
// Should have header element with proper structure
const header = container.firstChild as HTMLElement
expect(header?.tagName).toBe('HEADER')
// Should have a nav element
const nav = container.querySelector('nav')
expect(nav).toBeInTheDocument()
// Should be sticky positioned
expect(header).toHaveClass('sticky', 'top-0')
})
})
+70
View File
@@ -0,0 +1,70 @@
import { siteConfig } from '@/config/site'
export default function Header() {
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex-shrink-0">
<a href="/" className="text-xl font-bold text-blue-600 hover:text-blue-700">
{siteConfig.general.name}
</a>
</div>
{/* Desktop Navigation */}
<div className="hidden md:block">
<div className="flex items-center space-x-8">
{siteConfig.navigation.main.map((item) => (
<a
key={item.href}
href={item.href}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
className={item.label === 'Kapcsolat'
? "bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
}
>
{item.label}
</a>
))}
</div>
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
type="button"
className="text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-expanded="false"
>
<span className="sr-only">Open main menu</span>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
{/* Mobile Navigation - Hidden by default */}
<div className="md:hidden absolute top-full left-0 right-0 bg-white border-b border-gray-200 shadow-lg opacity-0 invisible transition-all duration-300 ease-in-out">
<div className="px-2 pt-2 pb-3 space-y-1">
<a href="/" className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:text-blue-600">
Kezdőlap
</a>
<a href="/rolunk" className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:text-blue-600">
Rólunk
</a>
<a href="/szolgaltatasok" className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:text-blue-600">
Szolgáltatások
</a>
<a href="/kapcsolat" className="block px-3 py-2 rounded-md text-base font-medium bg-blue-600 text-white">
Kapcsolat
</a>
</div>
</div>
</nav>
</header>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { SiteConfig } from '@/types/site'
/**
* Site Configuration - Centralized configuration for all public content
* All content is easily modifiable without code changes
* This structure supports easy expansion for CMS integration later
*/
export const siteConfig: SiteConfig = {
general: {
name: 'mozdIT Bt.',
description: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://localhost:3000',
ogImage: '/og-image.png',
locale: 'hu-HU'
},
navigation: {
main: [
{ label: 'Kezdőlap', href: '/' },
{ label: 'Rólunk', href: '/rolunk' },
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
{ label: 'Kapcsolat', href: '/kapcsolat' }
],
footer: [
{ label: 'Kezdőlap', href: '/' },
{ label: 'Rólunk', href: '/rolunk' },
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
{ label: 'Kapcsolat', href: '/kapcsolat' },
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
]
},
hero: {
title: 'Megbízható web és emailszolgáltatás személyre szabott támogatással',
subtitle: 'Kis ügyfélkör, nagy figyelem: stabil tárhely, üzembiztos levelezés és DNS adminisztráció — gyors reakcióval.',
description: 'A mozdIT Bt. célja, hogy megbízható, támogató szolgáltatásokkal segítse ügyfeleit a digitális térben való sikerben.',
cta: {
primary: {
text: 'Webmail Ugrás',
href: process.env.NEXT_PUBLIC_WEBMAIL_URL || 'https://webmail.mozdit.hu',
external: true
},
secondary: {
text: 'Kapcsolatfelvétel',
href: '/kapcsolat'
}
}
},
services: {
title: 'Szolgáltatásaink',
subtitle: 'Komplex megoldásokat kínálunk, amelyek tökéletesen igazodnak a vállalkozás igényeihez.',
services: [
{
id: 'hosting',
title: 'Web Hosting',
description: 'Stabil és gyors tárhely szolgáltatás megbízható infrastrukturával és folyamatos monitoringgel.',
icon: '🔥',
features: [
'99.9% uptime garancia',
'SSD tárhely gyorsabb válaszidőért',
'24/7 monitoring és támogatás',
'Automatikus backup rendszer',
'SSL tanúsítvány belefoglaltva'
],
ctaText: 'További információk'
},
{
id: 'email',
title: 'Email Szolgáltatás',
description: 'Üzembiztos levelezési megoldások modern biztonsággal és anti-spam védelemmel.',
icon: '📧',
features: [
'Domain alapú email címe',
'Webmail és IMAP/POP3 támogatása',
'Anti-spam és anti-virus védelem',
'Mobil szinkronizáció',
'Nagy tárterület 10GB/felhasználó'
],
ctaText: 'További információk'
},
{
id: 'dns',
title: 'DNS Adminisztráció',
description: 'Teljeskörű domain névszerver kezelés és optimalizálás gyors névfeloldással.',
icon: '🌐',
features: [
'Biztonságos DNS konfliktus kezelés',
'Email routing konfiguráció',
'Subdomain management',
'DNSSEC támogatás',
'FIGYELEM: Senki ne módosítson nélkülem kérem!'
],
ctaText: 'További információk'
}
]
},
about: {
title: 'Miért érdemes minket választani?',
description: [
'A mozdIT Bt. 2018 óta nyújt megbízható Web Hosting szolgáltatásokat magyar vállalkozások számára.',
'Kis ügyfélközpontú csapatunknak köszönhetően személyre szabott és gyors támogatást tudunk biztosítani.'
],
usps: [
{
id: 'personal',
title: 'Személyes ügyfélkezelés',
description: 'Egyedi figyelem minden ügyfél felé, személyre szabott megoldásokkal.',
icon: '👤'
},
{
id: 'fast',
title: 'Gyors reagálás',
description: 'Azonnali visszajelzés és hatékony problémamegoldás 24 órás támogatással.',
icon: '⚡'
},
{
id: 'reliable',
title: 'Stabil háttér',
description: 'Biztonságos infrastruktúra és rendszeres mentések, megbízhatóság garanciával.',
icon: '🛡️'
},
{
id: 'flexible',
title: 'Rugalmas támogatás',
description: 'A változó igényekhez alkalmazkodó, örökös technikai karbantartás.',
icon: '🔧'
}
]
},
footer: {
copyright: `© ${new Date().getFullYear()} mozdIT Bt. Minden jog fenntartva.`,
links: [
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
]
},
contact: {
email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'info@mozdit.hu',
address: 'Budapest, Magyarország',
form: {
title: 'Kapcsolatfelvétel',
description: 'Legyen szíves érdeklődését vagy problémáját részletesen megfogalmazni.',
submitText: 'Üzenet küldése',
fields: {
name: {
label: 'Név',
placeholder: 'Vezetéknév Keresztnév',
required: true
},
email: {
label: 'Email cím',
placeholder: 'pelda@email.hu',
required: true
},
message: {
label: 'Üzenet',
placeholder: 'Kérjük írja le érdeklődését részletesen...',
required: true
},
consent: {
label: 'Elfogadom az adatkezelési tájékoztatót',
required: true
}
}
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import winston from 'winston'
// Mock winston and winston-loki
jest.mock('winston', () => ({
format: {
combine: jest.fn(),
timestamp: jest.fn(),
errors: jest.fn(),
json: jest.fn(),
colorize: jest.fn(),
simple: jest.fn(),
printf: jest.fn()
},
transports: {
Console: jest.fn(),
File: jest.fn()
},
createLogger: jest.fn()
}))
jest.mock('winston-loki', () => jest.fn())
describe('Logger', () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset process.env
process.env = {
...process.env,
NODE_ENV: 'test',
LOKI_HOST: undefined,
LOKI_USERNAME: undefined,
LOKI_PASSWORD: undefined
}
})
it('should create logger with correct configuration', () => {
// Mock the format functions
const mockFormat = {
combine: jest.fn().mockReturnValue('combined-format'),
timestamp: jest.fn().mockReturnValue('timestamp-format'),
errors: jest.fn().mockReturnValue('errors-format'),
json: jest.fn().mockReturnValue('json-format'),
colorize: jest.fn().mockReturnValue('colorize-format'),
simple: jest.fn().mockReturnValue('simple-format'),
printf: jest.fn().mockReturnValue('printf-format')
}
const mockTransports = {
Console: jest.fn().mockImplementation(() => ({ name: 'console' })),
File: jest.fn().mockImplementation(() => ({ name: 'file' }))
}
// Setup mocks
;(winston.format as any) = mockFormat
;(winston.transports as any) = mockTransports
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue({
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnValue({
info: jest.fn(),
error: jest.fn(),
debug: jest.fn()
}),
end: jest.fn()
})
// Import after mocks are set up
const { logger } = require('./logger')
expect(winston.createLogger).toHaveBeenCalledWith(
expect.objectContaining({
level: 'debug',
format: 'combined-format',
defaultMeta: expect.objectContaining({
service: 'mozdit-web',
environment: 'test'
}),
transports: expect.arrayContaining([
expect.objectContaining({ name: 'console' })
])
})
)
})
it('should include Loki transport when LOKI_HOST is provided', () => {
// Set LOKI_HOST
process.env.LOKI_HOST = 'http://localhost:3100'
process.env.LOKI_USERNAME = 'test'
process.env.LOKI_PASSWORD = 'testpass'
// Reset modules to pick up new env vars
jest.resetModules()
// Mock LokiTransport
const mockLokiTransport = jest.fn().mockImplementation(() => ({
name: 'loki'
}))
jest.doMock('winston-loki', () => mockLokiTransport)
// Import after mocks are set up
require('./logger')
expect(mockLokiTransport).toHaveBeenCalledWith(
expect.objectContaining({
host: 'http://localhost:3100',
labels: expect.objectContaining({
app: 'mozdit-web',
environment: 'test',
service: 'frontend'
}),
basicAuth: 'test:testpass'
})
)
})
it('should create component loggers with correct metadata', () => {
// Setup mocks
const mockLogger = {
child: jest.fn().mockReturnValue({
info: jest.fn(),
error: jest.fn()
})
}
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue(mockLogger)
// Import after mocks are set up
const { createComponentLogger } = require('./logger')
const componentLogger = createComponentLogger('test-component')
expect(mockLogger.child).toHaveBeenCalledWith({ component: 'test-component' })
expect(componentLogger).toBeDefined()
})
it('should generate request IDs in correct format', () => {
// Setup mocks
jest.resetModules()
const { generateRequestId } = require('./logger')
const requestId = generateRequestId()
expect(requestId).toMatch(/^\d+-[a-z0-9]+$/)
})
it('should handle timing operations correctly', async () => {
// Setup mocks
const mockLogger = {
debug: jest.fn(),
error: jest.fn()
}
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue(mockLogger)
jest.resetModules()
const { withTiming } = require('./logger')
const mockOperation = jest.fn().mockResolvedValue('success')
const result = await withTiming('test-operation', mockOperation, { test: 'metadata' })
expect(result).toBe('success')
expect(mockLogger.debug).toHaveBeenCalledTimes(2) // Start and complete
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Started test-operation'),
{ test: 'metadata' }
)
})
it('should handle timing operation failures', async () => {
// Setup mocks
const mockLogger = {
debug: jest.fn(),
error: jest.fn()
}
;(winston.createLogger as jest.Mock) = jest.fn().mockReturnValue(mockLogger)
jest.resetModules()
const { withTiming } = require('./logger')
const mockOperation = jest.fn().mockRejectedValue(new Error('test error'))
await expect(withTiming('test-operation', mockOperation)).rejects.toThrow('test error')
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Started test-operation'),
{}
)
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed test-operation'),
expect.objectContaining({
duration: expect.any(Number),
error: expect.any(Error)
})
)
})
})
+120
View File
@@ -0,0 +1,120 @@
import winston from 'winston'
import LokiTransport from 'winston-loki'
const isDevelopment = process.env.NODE_ENV === 'development'
// Custom format for structured logging
const structuredFormat = winston.format.combine(
winston.format.timestamp({ format: 'ISO' }),
winston.format.errors({ stack: true }),
winston.format.json({
replacer: (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
})
)
// Console format for development
const consoleFormat = winston.format.combine(
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.colorize(),
winston.format.simple(),
winston.format.printf(({ timestamp, level, message, service, requestId, ...meta }) => {
const requestInfo = requestId ? `[${requestId}]` : ''
const serviceInfo = service ? `[${service}]` : '[mozdIT]'
const metaStr = Object.keys(meta).length ? `\n${JSON.stringify(meta, null, 2)}` : ''
return `${timestamp} ${serviceInfo} ${level} ${requestInfo} ${message}${metaStr}`
})
)
// Transports configuration
const transports: winston.transport[] = [
// Loki transport for centralized logging
...(process.env.LOKI_HOST
? [
new LokiTransport({
host: process.env.LOKI_HOST,
labels: {
app: 'mozdit-web',
environment: process.env.NODE_ENV || 'development',
service: 'frontend'
},
basicAuth: process.env.LOKI_USERNAME && process.env.LOKI_PASSWORD
? `${process.env.LOKI_USERNAME}:${process.env.LOKI_PASSWORD}`
: undefined,
json: true,
format: winston.format.json(),
onConnectionError: (err: Error) => console.error('Loki connection error:', err)
})
]
: []),
// Console for development logging
new winston.transports.Console({
level: isDevelopment ? 'debug' : 'info',
format: isDevelopment ? consoleFormat : structuredFormat,
handleExceptions: true,
handleRejections: true
})
]
// Root logger configuration
export const logger = winston.createLogger({
level: isDevelopment ? 'debug' : 'info',
format: structuredFormat,
defaultMeta: {
service: 'mozdit-web',
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development'
},
transports,
exceptionHandlers: transports,
rejectionHandlers: transports
})
// Specialized loggers for different components
export const createComponentLogger = (component: string) => {
return logger.child({ component })
}
export const requestLogger = logger.child({ component: 'request' })
export const apiLogger = logger.child({ component: 'api' })
export const dbLogger = logger.child({ component: 'database' })
export const authLogger = logger.child({ component: 'auth' })
export const errorLogger = logger.child({ component: 'error' })
// Request ID generator for correlation
export const generateRequestId = (): string =>
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
// Helper function for timing operations
export const withTiming = async <T>(
operation: string,
fn: () => Promise<T>,
metadata: any = {}
): Promise<T> => {
const startTime = Date.now()
logger.debug(`Started ${operation}`, metadata)
try {
const result = await fn()
const duration = Date.now() - startTime
logger.debug(`Completed ${operation} in ${duration}ms`, { ...metadata, duration })
return result
} catch (error) {
const duration = Date.now() - startTime
logger.error(`Failed ${operation} in ${duration}ms`, { ...metadata, duration, error })
throw error
}
}
// Graceful shutdown
const gracefulShutdown = () => {
logger.info('Initiating graceful shutdown...')
logger.end()
}
process.on('SIGTERM', gracefulShutdown)
process.on('SIGINT', gracefulShutdown)
export default logger
+154
View File
@@ -0,0 +1,154 @@
import {
getCollection,
getDb,
checkMongoConnection
} from './mongodb'
import { MongoClient } from 'mongodb'
const mockMongoClient = MongoClient as jest.MockedClass<typeof MongoClient>
// Mock the MongoDB client and database
const mockDb = {
collection: jest.fn().mockReturnValue({
findOne: jest.fn(),
insertOne: jest.fn(),
updateOne: jest.fn(),
deleteOne: jest.fn()
})
}
const mockClient = {
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
db: jest.fn().mockReturnValue(mockDb)
}
// Mock the mongodb module
jest.mock('mongodb', () => ({
MongoClient: jest.fn().mockImplementation(() => mockClient)
}))
// Restore environment before tests
const originalEnv = process.env
describe('MongoDB Connection', () => {
beforeEach(() => {
process.env = {
...originalEnv,
MONGODB_URI: 'mongodb://localhost:27017/test',
MONGODB_DB: 'test'
}
jest.clearAllMocks()
})
afterEach(() => {
process.env = originalEnv
})
describe('MongoDB URI validation', () => {
it('should throw error when MONGODB_URI is not set', () => {
// This test is tricky because the module is cached
// We'll test this by clearing the module cache and mocking process.env
const originalMongodbUri = process.env.MONGODB_URI
delete process.env.MONGODB_URI
// Clear module cache to force re-import
jest.resetModules()
expect(() => {
require('./mongodb')
}).toThrow('Please add MONGODB_URI to your environment variables')
// Restore environment
process.env.MONGODB_URI = originalMongodbUri
})
})
describe('Database connection', () => {
it('should create MongoClient with correct URI and options', async () => {
const { MongoClient } = require('mongodb')
// Reset modules to use our mock
jest.resetModules()
const { clientPromise } = require('./mongodb')
await clientPromise
expect(MongoClient).toHaveBeenCalledWith(
process.env.MONGODB_URI,
expect.objectContaining({
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000
})
)
})
it('should return database instance', async () => {
// Reset modules to use our mock
jest.resetModules()
const { getDb } = require('./mongodb')
const result = await getDb()
expect(result).toBeDefined()
expect(typeof result.collection).toBe('function')
})
it('should return collection from database', async () => {
// Reset modules to use our mock
jest.resetModules()
const { getCollection } = require('./mongodb')
const collection = await getCollection('test_collection')
expect(collection).toBeDefined()
expect(typeof collection.findOne).toBe('function')
})
it('should check MongoDB connection successfully', async () => {
// Reset modules to use our mock
jest.resetModules()
const { checkMongoConnection } = require('./mongodb')
const result = await checkMongoConnection()
expect(result).toBe(true)
})
it('should return false on MongoDB connection failure', async () => {
// Mock a connection failure
const { MongoClient } = require('mongodb')
// Create a failing client
const failingClient = {
connect: jest.fn().mockRejectedValue(new Error('Connection failed')),
db: jest.fn(),
close: jest.fn()
}
MongoClient.mockImplementation(() => failingClient)
// Reset the module to use the new mock
jest.resetModules()
const { checkMongoConnection } = require('./mongodb')
const result = await checkMongoConnection()
expect(result).toBe(false)
})
})
describe('Connection reuse and caching', () => {
it('should reuse the same MongoClient instance for multiple calls', async () => {
// Reset modules to use our mock
jest.resetModules()
const { clientPromise: clientPromise1 } = require('./mongodb')
const { clientPromise: clientPromise2 } = require('./mongodb')
await clientPromise1
await clientPromise2
// Should still be the same promise from cache
expect(clientPromise1).toBe(clientPromise2)
})
})
})
+55
View File
@@ -0,0 +1,55 @@
import { MongoClient, Db } from 'mongodb'
if (!process.env.MONGODB_URI) {
throw new Error('Please add MONGODB_URI to your environment variables')
}
const uri = process.env.MONGODB_URI
const options = {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
}
let client: MongoClient
let clientPromise: Promise<MongoClient>
// In development mode, use a global variable so that the client is not recreated between hot reloads
if (process.env.NODE_ENV === 'development') {
// @ts-ignore
if (!global._mongoClientPromise) {
client = new MongoClient(uri, options)
// @ts-ignore
global._mongoClientPromise = client.connect()
}
// @ts-ignore
clientPromise = global._mongoClientPromise
} else {
// In production mode, it's best to not use a global variable
client = new MongoClient(uri, options)
clientPromise = client.connect()
}
export default clientPromise
export async function getDb(): Promise<Db> {
const client = await clientPromise
return client.db(process.env.MONGODB_DB || 'mozdit')
}
export async function getCollection(collectionName: string) {
const db = await getDb()
return db.collection(collectionName)
}
// Health check for MongoDB connection
export async function checkMongoConnection(): Promise<boolean> {
try {
const db = await getDb()
await db.admin().ping()
return true
} catch (error) {
console.error('MongoDB connection check failed:', error)
return false
}
}
+382
View File
@@ -0,0 +1,382 @@
import { getSiteConfig, saveSiteConfig, initializeDefaultConfig } from './site-config'
import { getCollection } from './mongodb'
import { siteConfig as staticConfig } from '@/config/site'
import { SiteConfig } from '@/types/site'
import logger from './logger'
// Mock dependencies
jest.mock('./mongodb')
jest.mock('./logger', () => ({
__esModule: true,
default: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}
}))
jest.mock('@/config/site', () => ({
siteConfig: {
general: {
name: 'Test Site',
description: 'Test Description',
url: 'https://test.com',
ogImage: 'https://test.com/og.jpg',
locale: 'en'
},
navigation: {
main: [
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about' }
],
footer: [
{ label: 'Privacy', href: '/privacy' },
{ label: 'Terms', href: '/terms' }
]
},
hero: {
title: 'Welcome',
subtitle: 'Test Subtitle',
description: 'Test description',
cta: {
primary: {
text: 'Get Started',
href: '/get-started'
}
}
},
services: {
title: 'Our Services',
subtitle: 'What we offer',
services: []
},
about: {
title: 'About Us',
description: ['Test about section'],
usps: []
},
footer: {
copyright: '© 2024 Test Site',
links: []
},
contact: {
email: 'test@test.com',
address: 'Test Address',
form: {
title: 'Contact Us',
description: 'Get in touch',
submitText: 'Send Message',
fields: {
name: { label: 'Name', placeholder: 'Your name', required: true },
email: { label: 'Email', placeholder: 'your@email.com', required: true },
message: { label: 'Message', placeholder: 'Your message', required: true },
consent: { label: 'I agree', required: true }
}
}
}
}
}))
const mockCollection = {
findOne: jest.fn(),
replaceOne: jest.fn()
}
describe('Site Config', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getCollection as jest.Mock).mockResolvedValue(mockCollection)
})
describe('getSiteConfig', () => {
it('should return MongoDB config when available', async () => {
const mockConfig: SiteConfig = {
general: {
name: 'MongoDB Site',
description: 'MongoDB Description',
url: 'https://mongodb.com',
ogImage: 'https://mongodb.com/og.jpg',
locale: 'en'
},
navigation: {
main: [{ label: 'Home', href: '/' }],
footer: [{ label: 'Privacy', href: '/privacy' }]
},
hero: {
title: 'MongoDB Hero',
subtitle: 'MongoDB Subtitle',
description: 'MongoDB Description',
cta: {
primary: {
text: 'Get Started',
href: '/get-started'
}
}
},
services: {
title: 'Services',
subtitle: 'Our services',
services: []
},
about: {
title: 'About',
description: ['About us'],
usps: []
},
footer: {
copyright: '© 2024 MongoDB',
links: []
},
contact: {
email: 'mongodb@test.com',
address: 'MongoDB Address',
form: {
title: 'Contact',
description: 'Get in touch',
submitText: 'Send',
fields: {
name: { label: 'Name', placeholder: 'Name', required: true },
email: { label: 'Email', placeholder: 'Email', required: true },
message: { label: 'Message', placeholder: 'Message', required: true },
consent: { label: 'Consent', required: true }
}
}
}
}
mockCollection.findOne.mockResolvedValue({
_id: 'mock-id',
data: mockConfig
})
const config = await getSiteConfig()
expect(config).toEqual(mockConfig)
expect(mockCollection.findOne).toHaveBeenCalledWith({
type: 'site_config',
environment: 'development'
})
expect(logger.info).toHaveBeenCalledWith(
'Loaded site config from MongoDB',
{ configId: 'mock-id' }
)
})
it('should return static config when MongoDB config not found', async () => {
mockCollection.findOne.mockResolvedValue(null)
const config = await getSiteConfig()
expect(config).toEqual(staticConfig)
expect(logger.info).toHaveBeenCalledWith(
'MongoDB config not found, using static fallback'
)
})
it('should return static config when MongoDB is unavailable', async () => {
mockCollection.findOne.mockRejectedValue(new Error('Connection failed'))
const config = await getSiteConfig()
expect(config).toEqual(staticConfig)
expect(logger.warn).toHaveBeenCalledWith(
'MongoDB unavailable, using static config',
{ error: 'Connection failed' }
)
})
})
describe('saveSiteConfig', () => {
it('should save config to MongoDB successfully', async () => {
const newConfig: SiteConfig = {
general: {
name: 'New Site',
description: 'New Description',
url: 'https://new.com',
ogImage: 'https://new.com/og.jpg',
locale: 'en'
},
navigation: {
main: [{ label: 'Home', href: '/' }],
footer: [{ label: 'Privacy', href: '/privacy' }]
},
hero: {
title: 'New Hero',
subtitle: 'New Subtitle',
description: 'New Description',
cta: {
primary: {
text: 'Get Started',
href: '/get-started'
}
}
},
services: {
title: 'Services',
subtitle: 'Our services',
services: []
},
about: {
title: 'About',
description: ['About us'],
usps: []
},
footer: {
copyright: '© 2024 New',
links: []
},
contact: {
email: 'new@test.com',
address: 'New Address',
form: {
title: 'Contact',
description: 'Get in touch',
submitText: 'Send',
fields: {
name: { label: 'Name', placeholder: 'Name', required: true },
email: { label: 'Email', placeholder: 'Email', required: true },
message: { label: 'Message', placeholder: 'Message', required: true },
consent: { label: 'Consent', required: true }
}
}
}
}
mockCollection.replaceOne.mockResolvedValue({ acknowledged: true })
const result = await saveSiteConfig(newConfig)
expect(result).toBe(true)
expect(mockCollection.replaceOne).toHaveBeenCalledWith(
{ type: 'site_config', environment: 'development' },
{
type: 'site_config',
environment: 'development',
data: newConfig,
lastModified: expect.any(Date)
},
{ upsert: true }
)
expect(logger.info).toHaveBeenCalledWith('Saved site config to MongoDB')
})
it('should return false when save fails', async () => {
const newConfig: SiteConfig = {
general: {
name: 'New Site',
description: 'New Description',
url: 'https://new.com',
ogImage: 'https://new.com/og.jpg',
locale: 'en'
},
navigation: {
main: [{ label: 'Home', href: '/' }],
footer: [{ label: 'Privacy', href: '/privacy' }]
},
hero: {
title: 'New Hero',
subtitle: 'New Subtitle',
description: 'New Description',
cta: {
primary: {
text: 'Get Started',
href: '/get-started'
}
}
},
services: {
title: 'Services',
subtitle: 'Our services',
services: []
},
about: {
title: 'About',
description: ['About us'],
usps: []
},
footer: {
copyright: '© 2024 New',
links: []
},
contact: {
email: 'new@test.com',
address: 'New Address',
form: {
title: 'Contact',
description: 'Get in touch',
submitText: 'Send',
fields: {
name: { label: 'Name', placeholder: 'Name', required: true },
email: { label: 'Email', placeholder: 'Email', required: true },
message: { label: 'Message', placeholder: 'Message', required: true },
consent: { label: 'Consent', required: true }
}
}
}
}
mockCollection.replaceOne.mockRejectedValue(new Error('Save failed'))
const result = await saveSiteConfig(newConfig)
expect(result).toBe(false)
expect(logger.error).toHaveBeenCalledWith(
'Failed to save config to MongoDB',
{ error: 'Save failed' }
)
})
})
describe('initializeDefaultConfig', () => {
it('should initialize default config when none exists', async () => {
mockCollection.findOne.mockResolvedValue(null)
mockCollection.replaceOne.mockResolvedValue({ acknowledged: true })
await initializeDefaultConfig()
expect(mockCollection.findOne).toHaveBeenCalledWith({
type: 'site_config',
environment: 'development'
})
expect(mockCollection.replaceOne).toHaveBeenCalledWith(
{ type: 'site_config', environment: 'development' },
{
type: 'site_config',
environment: 'development',
data: staticConfig,
lastModified: expect.any(Date)
},
{ upsert: true }
)
expect(logger.info).toHaveBeenCalledWith(
'Initialized default site config in MongoDB'
)
})
it('should not initialize when config already exists', async () => {
mockCollection.findOne.mockResolvedValue({
_id: 'existing-id',
data: staticConfig
})
await initializeDefaultConfig()
expect(mockCollection.replaceOne).not.toHaveBeenCalled()
expect(logger.info).toHaveBeenCalledWith(
'Site config already exists in MongoDB'
)
})
it('should handle initialization errors gracefully', async () => {
mockCollection.findOne.mockRejectedValue(new Error('Connection failed'))
await initializeDefaultConfig()
expect(logger.error).toHaveBeenCalledWith(
'Failed to initialize site config',
{ error: 'Connection failed' }
)
})
})
})
+74
View File
@@ -0,0 +1,74 @@
// Hybrid approach: File-based fallback + MongoDB integration for future
import { siteConfig as staticConfig } from '@/config/site'
import { SiteConfig } from '@/types/site'
import { getCollection } from './mongodb'
import logger from './logger'
/**
* Get site configuration with hybrid approach
* 1. Try MongoDB first (production-ready)
* 2. Fall back to static file (development/development safe)
*/
export async function getSiteConfig(): Promise<SiteConfig> {
try {
const collection = await getCollection('site_config')
const doc = await collection.findOne({ type: 'site_config', environment: 'development' })
if (doc && doc.data) {
logger.info('Loaded site config from MongoDB', { configId: doc._id })
return doc.data as SiteConfig
} else {
logger.info('MongoDB config not found, using static fallback')
return staticConfig
}
} catch (error) {
logger.warn('MongoDB unavailable, using static config', { error: (error as Error).message })
return staticConfig
}
}
/**
* Save site configuration to MongoDB
* For future admin panel integration
*/
export async function saveSiteConfig(config: SiteConfig): Promise<boolean> {
try {
const collection = await getCollection('site_config')
await collection.replaceOne(
{ type: 'site_config', environment: 'development' },
{
type: 'site_config',
environment: 'development',
data: config,
lastModified: new Date()
},
{ upsert: true }
)
logger.info('Saved site config to MongoDB')
return true
} catch (error) {
logger.error('Failed to save config to MongoDB', { error: (error as Error).message })
return false
}
}
/**
* Initialize default config in MongoDB (one-time setup)
*/
export async function initializeDefaultConfig(): Promise<void> {
try {
const collection = await getCollection('site_config')
const existingConfig = await collection.findOne({ type: 'site_config', environment: 'development' })
if (!existingConfig) {
await saveSiteConfig(staticConfig)
logger.info('Initialized default site config in MongoDB')
} else {
logger.info('Site config already exists in MongoDB')
}
} catch (error) {
logger.error('Failed to initialize site config', { error: (error as Error).message })
}
}
+129
View File
@@ -0,0 +1,129 @@
export interface SiteConfig {
general: GeneralConfig
navigation: NavigationConfig
hero: HeroSectionConfig
services: ServiceSectionConfig
about: AboutSectionConfig
footer: FooterConfig
contact: ContactConfig
}
export interface GeneralConfig {
name: string
description: string
url: string
ogImage: string
locale: string
}
export interface NavigationItem {
label: string
href: string
external?: boolean
}
export interface NavigationConfig {
main: NavigationItem[]
footer: NavigationItem[]
}
export interface HeroSectionConfig {
title: string
subtitle: string
description: string
cta: {
primary: {
text: string
href: string
external?: boolean
}
secondary?: {
text: string
href: string
}
}
}
export interface ServiceItem {
id: string
title: string
description: string
icon: string
features: string[]
ctaText: string
}
export interface ServiceSectionConfig {
title: string
subtitle: string
services: ServiceItem[]
}
export interface USP {
id: string
title: string
description: string
icon: string
}
export interface AboutSectionConfig {
title: string
description: string[]
usps: USP[]
}
export interface FooterConfig {
copyright: string
links: {
label: string
href: string
}[]
}
export interface ContactConfig {
phone?: string
email: string
address: string
socialMedia?: {
platform: string
url: string
label: string
}[]
form: {
title: string
description: string
submitText: string
fields: {
name: {
label: string
placeholder: string
required: boolean
}
email: {
label: string
placeholder: string
required: boolean
}
message: {
label: string
placeholder: string
required: boolean
}
consent: {
label: string
required: boolean
}
}
}
}
export interface EnvConfig {
siteUrl: string
companyName: string
contactEmail: string
webmailUrl?: string
analytics: {
plausibleDomain?: string
ga4Id?: string
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}