feat: add allowed Linear API operations to MCP config
This commit is contained in:
@@ -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)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user