chore: overhaul infrastructure and integrate @mintel packages
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
- Restructure to pnpm monorepo (site moved to apps/web) - Integrate @mintel/tsconfig, @mintel/eslint-config, @mintel/husky-config - Implement Docker service architecture (Varnish, Directus, Gatekeeper) - Setup environment-aware Gitea Actions deployment
This commit is contained in:
107
apps/web/src/utils/analytics/index.ts
Normal file
107
apps/web/src/utils/analytics/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Analytics Service - Main entry point with DI
|
||||
* Clean constructor-based dependency injection
|
||||
*/
|
||||
|
||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
||||
import { PlausibleAdapter } from './plausible-adapter';
|
||||
import { UmamiAdapter, type UmamiConfig } from './umami-adapter';
|
||||
|
||||
export class AnalyticsService {
|
||||
private adapter: AnalyticsAdapter;
|
||||
|
||||
/**
|
||||
* Create analytics service with dependency injection
|
||||
* @param adapter - Analytics adapter implementation
|
||||
*/
|
||||
constructor(adapter: AnalyticsAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
getAdapter(): AnalyticsAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
async track(event: AnalyticsEvent): Promise<void> {
|
||||
return this.adapter.track(event);
|
||||
}
|
||||
|
||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
||||
if (this.adapter.page) {
|
||||
return this.adapter.page(path, props);
|
||||
}
|
||||
return this.track({ name: 'Pageview', props: { path, ...props } });
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
|
||||
if (this.adapter.identify) {
|
||||
return this.adapter.identify(userId, traits);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async trackEvent(name: string, props?: Record<string, any>): Promise<void> {
|
||||
return this.track({ name, props });
|
||||
}
|
||||
|
||||
async trackOutboundLink(url: string, text: string): Promise<void> {
|
||||
return this.track({
|
||||
name: 'Outbound Link',
|
||||
props: { url, text }
|
||||
});
|
||||
}
|
||||
|
||||
async trackSearch(query: string, path: string): Promise<void> {
|
||||
return this.track({
|
||||
name: 'Search',
|
||||
props: { query, path }
|
||||
});
|
||||
}
|
||||
|
||||
async trackPageLoad(loadTime: number, path: string, userAgent: string): Promise<void> {
|
||||
return this.track({
|
||||
name: 'Page Load',
|
||||
props: { loadTime: Math.round(loadTime), path, userAgent }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Factory functions
|
||||
export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService {
|
||||
return new AnalyticsService(new PlausibleAdapter(config));
|
||||
}
|
||||
|
||||
export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService {
|
||||
return new AnalyticsService(new UmamiAdapter(config));
|
||||
}
|
||||
|
||||
// Default singleton
|
||||
let defaultAnalytics: AnalyticsService | null = null;
|
||||
|
||||
export function getDefaultAnalytics(): AnalyticsService {
|
||||
if (!defaultAnalytics) {
|
||||
const provider = process.env.NEXT_PUBLIC_ANALYTICS_PROVIDER || 'plausible';
|
||||
|
||||
if (provider === 'umami') {
|
||||
defaultAnalytics = createUmamiAnalytics({
|
||||
websiteId: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || '',
|
||||
hostUrl: process.env.NEXT_PUBLIC_UMAMI_HOST_URL,
|
||||
});
|
||||
} else {
|
||||
defaultAnalytics = createPlausibleAnalytics({
|
||||
domain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'mintel.me',
|
||||
scriptUrl: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL || 'https://plausible.yourdomain.com/js/script.js'
|
||||
});
|
||||
}
|
||||
}
|
||||
return defaultAnalytics;
|
||||
}
|
||||
|
||||
// Convenience function
|
||||
export async function track(name: string, props?: Record<string, any>): Promise<void> {
|
||||
return getDefaultAnalytics().trackEvent(name, props);
|
||||
}
|
||||
|
||||
// Re-export for advanced usage
|
||||
export type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig };
|
||||
export { PlausibleAdapter };
|
||||
20
apps/web/src/utils/analytics/interfaces.ts
Normal file
20
apps/web/src/utils/analytics/interfaces.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Analytics interfaces - decoupled contracts
|
||||
*/
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
name: string;
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AnalyticsAdapter {
|
||||
track(event: AnalyticsEvent): Promise<void>;
|
||||
identify?(userId: string, traits?: Record<string, any>): Promise<void>;
|
||||
page?(path: string, props?: Record<string, any>): Promise<void>;
|
||||
getScriptTag?(): string;
|
||||
}
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
domain?: string;
|
||||
scriptUrl?: string;
|
||||
}
|
||||
38
apps/web/src/utils/analytics/plausible-adapter.ts
Normal file
38
apps/web/src/utils/analytics/plausible-adapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Plausible Analytics Adapter
|
||||
* Decoupled implementation
|
||||
*/
|
||||
|
||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
||||
|
||||
export class PlausibleAdapter implements AnalyticsAdapter {
|
||||
private domain: string;
|
||||
private scriptUrl: string;
|
||||
|
||||
constructor(config: AnalyticsConfig) {
|
||||
this.domain = config.domain || 'mintel.me';
|
||||
this.scriptUrl = config.scriptUrl || 'https://plausible.yourdomain.com/js/script.js';
|
||||
}
|
||||
|
||||
async track(event: AnalyticsEvent): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const w = window as any;
|
||||
if (w.plausible) {
|
||||
w.plausible(event.name, {
|
||||
props: event.props
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
||||
await this.track({
|
||||
name: 'Pageview',
|
||||
props: { path, ...props }
|
||||
});
|
||||
}
|
||||
|
||||
getScriptTag(): string {
|
||||
return `<script defer data-domain="${this.domain}" src="${this.scriptUrl}"></script>`;
|
||||
}
|
||||
}
|
||||
54
apps/web/src/utils/analytics/umami-adapter.ts
Normal file
54
apps/web/src/utils/analytics/umami-adapter.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Umami Analytics Adapter
|
||||
* Decoupled implementation
|
||||
*/
|
||||
|
||||
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
|
||||
|
||||
export interface UmamiConfig extends AnalyticsConfig {
|
||||
websiteId: string;
|
||||
hostUrl?: string;
|
||||
}
|
||||
|
||||
export class UmamiAdapter implements AnalyticsAdapter {
|
||||
private websiteId: string;
|
||||
private hostUrl: string;
|
||||
|
||||
constructor(config: UmamiConfig) {
|
||||
this.websiteId = config.websiteId;
|
||||
this.hostUrl = config.hostUrl || 'https://cloud.umami.is';
|
||||
}
|
||||
|
||||
async track(event: AnalyticsEvent): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const w = window as any;
|
||||
if (w.umami) {
|
||||
w.umami.track(event.name, event.props);
|
||||
}
|
||||
}
|
||||
|
||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
||||
// Umami tracks pageviews automatically by default,
|
||||
// but we can manually trigger it if needed.
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const w = window as any;
|
||||
if (w.umami) {
|
||||
w.umami.track(props?.name || 'pageview', { url: path, ...props });
|
||||
}
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
|
||||
// Umami doesn't have a direct 'identify' like Segment,
|
||||
// but we can track it as an event or session property if supported by the instance.
|
||||
await this.track({
|
||||
name: 'identify',
|
||||
props: { userId, ...traits }
|
||||
});
|
||||
}
|
||||
|
||||
getScriptTag(): string {
|
||||
return `<script async src="${this.hostUrl}/script.js" data-website-id="${this.websiteId}"></script>`;
|
||||
}
|
||||
}
|
||||
96
apps/web/src/utils/cache/file-adapter.ts
vendored
Normal file
96
apps/web/src/utils/cache/file-adapter.ts
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
export class FileCacheAdapter implements CacheAdapter {
|
||||
private cacheDir: string;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config?: CacheConfig & { cacheDir?: string }) {
|
||||
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
|
||||
this.prefix = config?.prefix || '';
|
||||
this.defaultTTL = config?.defaultTTL || 3600;
|
||||
|
||||
if (!existsSync(this.cacheDir)) {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(err => {
|
||||
console.error(`Failed to create cache directory: ${this.cacheDir}`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(key: string): string {
|
||||
const clean = key.replace(/[^a-z0-9]/gi, '_');
|
||||
if (clean.length > 64) {
|
||||
return crypto.createHash('md5').update(key).digest('hex');
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private getFilePath(key: string): string {
|
||||
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
|
||||
return path.join(this.cacheDir, `${safeKey}.json`);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (data.expiry && Date.now() > data.expiry) {
|
||||
await this.del(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.value;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
|
||||
const data = {
|
||||
value,
|
||||
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error(`Failed to write cache file: ${filePath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
if (existsSync(this.cacheDir)) {
|
||||
const files = await fs.readdir(this.cacheDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
await fs.unlink(path.join(this.cacheDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
101
apps/web/src/utils/cache/index.ts
vendored
Normal file
101
apps/web/src/utils/cache/index.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Cache Service - Main entry point with DI
|
||||
* Simple constructor-based dependency injection
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
import { MemoryCacheAdapter } from './memory-adapter';
|
||||
import { RedisCacheAdapter } from './redis-adapter';
|
||||
|
||||
export class CacheService {
|
||||
private adapter: CacheAdapter;
|
||||
|
||||
/**
|
||||
* Create cache service with dependency injection
|
||||
* @param adapter - Cache adapter implementation
|
||||
*/
|
||||
constructor(adapter: CacheAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return this.adapter.get<T>(key);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
return this.adapter.set(key, value, ttl);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
return this.adapter.del(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
return this.adapter.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache wrapper for functions
|
||||
* Returns cached result or executes function and caches result
|
||||
*/
|
||||
async wrap<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const result = await fn();
|
||||
await this.set(key, result, ttl);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Factory functions for easy instantiation
|
||||
export function createMemoryCache(config?: CacheConfig): CacheService {
|
||||
return new CacheService(new MemoryCacheAdapter(config));
|
||||
}
|
||||
|
||||
export function createRedisCache(config?: CacheConfig & { redisUrl?: string }): CacheService {
|
||||
return new CacheService(new RedisCacheAdapter(config));
|
||||
}
|
||||
|
||||
// Default singleton instance - uses Redis if available, otherwise Memory
|
||||
let defaultCache: CacheService | null = null;
|
||||
|
||||
export async function getDefaultCache(): Promise<CacheService> {
|
||||
if (!defaultCache) {
|
||||
// Try Redis first
|
||||
const redisAdapter = new RedisCacheAdapter({
|
||||
prefix: 'mintel:blog:',
|
||||
defaultTTL: 3600
|
||||
});
|
||||
|
||||
const redisAvailable = await redisAdapter.get('__test__')
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (redisAvailable) {
|
||||
console.log('✅ Using Redis cache');
|
||||
defaultCache = new CacheService(redisAdapter);
|
||||
} else {
|
||||
console.log('⚠️ Redis unavailable, using Memory cache');
|
||||
defaultCache = createMemoryCache({
|
||||
prefix: 'mintel:blog:',
|
||||
defaultTTL: 3600
|
||||
});
|
||||
}
|
||||
}
|
||||
return defaultCache;
|
||||
}
|
||||
|
||||
// Convenience function using default cache
|
||||
export async function withCache<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cache = await getDefaultCache();
|
||||
return cache.wrap(key, fn, ttl);
|
||||
}
|
||||
|
||||
// Re-export interfaces and adapters for advanced usage
|
||||
export type { CacheAdapter, CacheConfig };
|
||||
export { MemoryCacheAdapter, RedisCacheAdapter };
|
||||
15
apps/web/src/utils/cache/interfaces.ts
vendored
Normal file
15
apps/web/src/utils/cache/interfaces.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Cache interfaces - decoupled contracts
|
||||
*/
|
||||
|
||||
export interface CacheAdapter {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
del(key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
prefix?: string;
|
||||
defaultTTL?: number;
|
||||
}
|
||||
43
apps/web/src/utils/cache/memory-adapter.ts
vendored
Normal file
43
apps/web/src/utils/cache/memory-adapter.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Memory Cache Adapter
|
||||
* Simple in-memory implementation
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
|
||||
export class MemoryCacheAdapter implements CacheAdapter {
|
||||
private cache = new Map<string, { value: any; expiry: number }>();
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config: CacheConfig = {}) {
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() > item.expiry) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const finalTTL = ttl || this.defaultTTL;
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
expiry: Date.now() + (finalTTL * 1000)
|
||||
});
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
95
apps/web/src/utils/cache/redis-adapter.ts
vendored
Normal file
95
apps/web/src/utils/cache/redis-adapter.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Redis Cache Adapter
|
||||
* Decoupled Redis implementation
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
|
||||
export class RedisCacheAdapter implements CacheAdapter {
|
||||
private client: any = null;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
private redisUrl: string;
|
||||
|
||||
constructor(config: CacheConfig & { redisUrl?: string } = {}) {
|
||||
this.prefix = config.prefix || 'mintel:';
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
this.redisUrl = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
}
|
||||
|
||||
private async init(): Promise<boolean> {
|
||||
if (this.client !== null) return true;
|
||||
|
||||
try {
|
||||
const Redis = await import('ioredis');
|
||||
this.client = new Redis.default(this.redisUrl);
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
console.warn('Redis connection error:', err.message);
|
||||
this.client = null;
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('✅ Redis connected');
|
||||
});
|
||||
|
||||
// Test connection
|
||||
await this.client.set(this.prefix + '__test__', 'ok', 'EX', 1);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Redis unavailable:', error);
|
||||
this.client = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return null;
|
||||
|
||||
try {
|
||||
const data = await this.client.get(this.prefix + key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.warn('Redis get error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
const finalTTL = ttl || this.defaultTTL;
|
||||
await this.client.setex(this.prefix + key, finalTTL, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn('Redis set error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
await this.client.del(this.prefix + key);
|
||||
} catch (error) {
|
||||
console.warn('Redis del error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
const keys = await this.client.keys(this.prefix + '*');
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Redis clear error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/web/src/utils/cn.ts
Normal file
6
apps/web/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
76
apps/web/src/utils/error-tracking/glitchtip-adapter.ts
Normal file
76
apps/web/src/utils/error-tracking/glitchtip-adapter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* GlitchTip Error Tracking Adapter
|
||||
* GlitchTip is Sentry-compatible.
|
||||
*/
|
||||
|
||||
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
|
||||
|
||||
export class GlitchTipAdapter implements ErrorTrackingAdapter {
|
||||
private dsn: string;
|
||||
|
||||
constructor(config: ErrorTrackingConfig) {
|
||||
this.dsn = config.dsn;
|
||||
this.init(config);
|
||||
}
|
||||
|
||||
private init(config: ErrorTrackingConfig) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// In a real scenario, we would import @sentry/nextjs or @sentry/browser
|
||||
// For this implementation, we assume Sentry is available globally or
|
||||
// we provide the structure that would call the SDK.
|
||||
const w = window as any;
|
||||
if (w.Sentry) {
|
||||
w.Sentry.init({
|
||||
dsn: this.dsn,
|
||||
environment: config.environment || 'production',
|
||||
release: config.release,
|
||||
debug: config.debug || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
captureException(error: any, context?: ErrorContext): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as any;
|
||||
if (w.Sentry) {
|
||||
w.Sentry.captureException(error, context);
|
||||
} else {
|
||||
console.error('[GlitchTip] Exception captured (Sentry not loaded):', error, context);
|
||||
}
|
||||
}
|
||||
|
||||
captureMessage(message: string, context?: ErrorContext): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as any;
|
||||
if (w.Sentry) {
|
||||
w.Sentry.captureMessage(message, context);
|
||||
} else {
|
||||
console.log('[GlitchTip] Message captured (Sentry not loaded):', message, context);
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user: ErrorContext['user']): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as any;
|
||||
if (w.Sentry) {
|
||||
w.Sentry.setUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
setTag(key: string, value: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as any;
|
||||
if (w.Sentry) {
|
||||
w.Sentry.setTag(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
setExtra(key: string, value: any): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as any;
|
||||
if (w.Sentry) {
|
||||
w.Sentry.setExtra(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/web/src/utils/error-tracking/index.ts
Normal file
61
apps/web/src/utils/error-tracking/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Error Tracking Service - Main entry point with DI
|
||||
*/
|
||||
|
||||
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
|
||||
import { GlitchTipAdapter } from './glitchtip-adapter';
|
||||
|
||||
export class ErrorTrackingService {
|
||||
private adapter: ErrorTrackingAdapter;
|
||||
|
||||
constructor(adapter: ErrorTrackingAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
captureException(error: any, context?: ErrorContext): void {
|
||||
this.adapter.captureException(error, context);
|
||||
}
|
||||
|
||||
captureMessage(message: string, context?: ErrorContext): void {
|
||||
this.adapter.captureMessage(message, context);
|
||||
}
|
||||
|
||||
setUser(user: ErrorContext['user']): void {
|
||||
this.adapter.setUser(user);
|
||||
}
|
||||
|
||||
setTag(key: string, value: string): void {
|
||||
this.adapter.setTag(key, value);
|
||||
}
|
||||
|
||||
setExtra(key: string, value: any): void {
|
||||
this.adapter.setExtra(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function
|
||||
export function createGlitchTipErrorTracking(config: ErrorTrackingConfig): ErrorTrackingService {
|
||||
return new ErrorTrackingService(new GlitchTipAdapter(config));
|
||||
}
|
||||
|
||||
// Default singleton
|
||||
let defaultErrorTracking: ErrorTrackingService | null = null;
|
||||
|
||||
export function getDefaultErrorTracking(): ErrorTrackingService {
|
||||
if (!defaultErrorTracking) {
|
||||
defaultErrorTracking = createGlitchTipErrorTracking({
|
||||
dsn: process.env.NEXT_PUBLIC_GLITCHTIP_DSN || '',
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
}
|
||||
return defaultErrorTracking;
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export function captureException(error: any, context?: ErrorContext): void {
|
||||
getDefaultErrorTracking().captureException(error, context);
|
||||
}
|
||||
|
||||
export function captureMessage(message: string, context?: ErrorContext): void {
|
||||
getDefaultErrorTracking().captureMessage(message, context);
|
||||
}
|
||||
29
apps/web/src/utils/error-tracking/interfaces.ts
Normal file
29
apps/web/src/utils/error-tracking/interfaces.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Error Tracking interfaces - decoupled contracts
|
||||
*/
|
||||
|
||||
export interface ErrorContext {
|
||||
extra?: Record<string, any>;
|
||||
tags?: Record<string, string>;
|
||||
user?: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
};
|
||||
level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||
}
|
||||
|
||||
export interface ErrorTrackingAdapter {
|
||||
captureException(error: any, context?: ErrorContext): void;
|
||||
captureMessage(message: string, context?: ErrorContext): void;
|
||||
setUser(user: ErrorContext['user']): void;
|
||||
setTag(key: string, value: string): void;
|
||||
setExtra(key: string, value: any): void;
|
||||
}
|
||||
|
||||
export interface ErrorTrackingConfig {
|
||||
dsn: string;
|
||||
environment?: string;
|
||||
release?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
261
apps/web/src/utils/test-component-integration.ts
Normal file
261
apps/web/src/utils/test-component-integration.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Test the integration between blog posts and file examples
|
||||
* This simulates what happens when a blog post is rendered
|
||||
*/
|
||||
|
||||
import { blogPosts } from '../data/blogPosts';
|
||||
import { FileExampleManager } from '../data/fileExamples';
|
||||
|
||||
export async function testBlogPostIntegration() {
|
||||
console.log('🧪 Testing Blog Post + File Examples Integration...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const test = (name: string, fn: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = fn();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(() => {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
});
|
||||
} else {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1: Blog posts exist
|
||||
test('Blog posts are loaded', () => {
|
||||
if (!blogPosts || blogPosts.length === 0) {
|
||||
throw new Error('No blog posts found');
|
||||
}
|
||||
console.log(` Found ${blogPosts.length} posts`);
|
||||
});
|
||||
|
||||
// Test 2: Each post has required fields
|
||||
test('All posts have required fields', () => {
|
||||
for (const post of blogPosts) {
|
||||
if (!post.slug || !post.title || !post.tags) {
|
||||
throw new Error(`Post ${post.slug} missing required fields`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Debugging-tips post should have file examples
|
||||
test('debugging-tips post has file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'debugging-tips');
|
||||
if (!post) {
|
||||
throw new Error('debugging-tips post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
// debugging-tips has tags ['debugging', 'tools'] so showFileExamples would be false
|
||||
// But it has hardcoded FileExamplesList in the template
|
||||
|
||||
// Verify files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'debugging-tips');
|
||||
|
||||
if (filesForPost.length === 0) {
|
||||
throw new Error('No files found for debugging-tips');
|
||||
}
|
||||
|
||||
console.log(` Found ${filesForPost.length} files for debugging-tips`);
|
||||
});
|
||||
|
||||
// Test 4: Architecture-patterns post should have file examples
|
||||
test('architecture-patterns post has file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'architecture-patterns');
|
||||
if (!post) {
|
||||
throw new Error('architecture-patterns post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
if (!showFileExamples) {
|
||||
throw new Error('architecture-patterns should show file examples');
|
||||
}
|
||||
|
||||
// Verify files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'architecture-patterns');
|
||||
|
||||
if (filesForPost.length === 0) {
|
||||
throw new Error('No files found for architecture-patterns');
|
||||
}
|
||||
|
||||
console.log(` Found ${filesForPost.length} files for architecture-patterns`);
|
||||
});
|
||||
|
||||
// Test 5: Docker-deployment post should have file examples
|
||||
test('docker-deployment post has file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'docker-deployment');
|
||||
if (!post) {
|
||||
throw new Error('docker-deployment post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
if (!showFileExamples) {
|
||||
throw new Error('docker-deployment should show file examples');
|
||||
}
|
||||
|
||||
// Verify files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'docker-deployment');
|
||||
|
||||
if (filesForPost.length === 0) {
|
||||
throw new Error('No files found for docker-deployment');
|
||||
}
|
||||
|
||||
console.log(` Found ${filesForPost.length} files for docker-deployment`);
|
||||
});
|
||||
|
||||
// Test 6: First-note post should NOT have file examples
|
||||
test('first-note post has no file examples', async () => {
|
||||
const post = blogPosts.find(p => p.slug === 'first-note');
|
||||
if (!post) {
|
||||
throw new Error('first-note post not found');
|
||||
}
|
||||
|
||||
// Check if it would trigger file examples
|
||||
const showFileExamples = post.tags?.some(tag =>
|
||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
||||
);
|
||||
|
||||
if (showFileExamples) {
|
||||
throw new Error('first-note should NOT show file examples');
|
||||
}
|
||||
|
||||
// Verify no files exist for this post
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'first-note');
|
||||
|
||||
if (filesForPost.length > 0) {
|
||||
throw new Error('Files found for first-note, but none should exist');
|
||||
}
|
||||
|
||||
console.log(` Correctly has no files`);
|
||||
});
|
||||
|
||||
// Test 7: Simulate FileExamplesList filtering for debugging-tips
|
||||
test('FileExamplesList filtering works for debugging-tips', async () => {
|
||||
const postSlug = 'debugging-tips';
|
||||
const groupId = 'python-data-processing';
|
||||
|
||||
const allGroups = await FileExampleManager.getAllGroups();
|
||||
const loadedGroups = allGroups
|
||||
.filter(g => g.groupId === groupId)
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f => f.postSlug === postSlug)
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (loadedGroups.length === 0) {
|
||||
throw new Error('No groups loaded for debugging-tips with python-data-processing');
|
||||
}
|
||||
|
||||
if (loadedGroups[0].files.length === 0) {
|
||||
throw new Error('No files in the group');
|
||||
}
|
||||
|
||||
console.log(` Would show ${loadedGroups[0].files.length} files`);
|
||||
});
|
||||
|
||||
// Test 8: Simulate FileExamplesList filtering for architecture-patterns
|
||||
test('FileExamplesList filtering works for architecture-patterns', async () => {
|
||||
const postSlug = 'architecture-patterns';
|
||||
const tags = ['architecture', 'design-patterns', 'system-design'];
|
||||
|
||||
const allGroups = await FileExampleManager.getAllGroups();
|
||||
const loadedGroups = allGroups
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f => {
|
||||
if (f.postSlug !== postSlug) return false;
|
||||
if (tags && tags.length > 0) {
|
||||
return f.tags?.some(tag => tags.includes(tag));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (loadedGroups.length === 0) {
|
||||
throw new Error('No groups loaded for architecture-patterns');
|
||||
}
|
||||
|
||||
const totalFiles = loadedGroups.reduce((sum, g) => sum + g.files.length, 0);
|
||||
if (totalFiles === 0) {
|
||||
throw new Error('No files found');
|
||||
}
|
||||
|
||||
console.log(` Would show ${totalFiles} files across ${loadedGroups.length} groups`);
|
||||
});
|
||||
|
||||
// Test 9: Verify all file examples have postSlug
|
||||
test('All file examples have postSlug property', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filesWithoutPostSlug = groups.flatMap(g => g.files).filter(f => !f.postSlug);
|
||||
|
||||
if (filesWithoutPostSlug.length > 0) {
|
||||
throw new Error(`${filesWithoutPostSlug.length} files missing postSlug`);
|
||||
}
|
||||
|
||||
console.log(` All ${groups.flatMap(g => g.files).length} files have postSlug`);
|
||||
});
|
||||
|
||||
// Test 10: Verify postSlugs match blog post slugs
|
||||
test('File example postSlugs match blog post slugs', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filePostSlugs = new Set(groups.flatMap(g => g.files).map(f => f.postSlug));
|
||||
const blogPostSlugs = new Set(blogPosts.map(p => p.slug));
|
||||
|
||||
for (const slug of filePostSlugs) {
|
||||
if (slug && !blogPostSlugs.has(slug)) {
|
||||
throw new Error(`File postSlug "${slug}" doesn't match any blog post`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` All file postSlugs match blog posts`);
|
||||
});
|
||||
|
||||
// Wait for async tests
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`\n📊 Integration Test Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('🎉 All integration tests passed!');
|
||||
console.log('\n✅ The file examples system is correctly integrated with blog posts!');
|
||||
} else {
|
||||
console.log('❌ Some integration tests failed');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other test files
|
||||
264
apps/web/src/utils/test-file-examples.ts
Normal file
264
apps/web/src/utils/test-file-examples.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Comprehensive tests for the file examples system
|
||||
*/
|
||||
|
||||
import { FileExampleManager, sampleFileExamples, type FileExample } from '../data/fileExamples';
|
||||
|
||||
// Test helper to run all tests
|
||||
export async function runFileExamplesTests() {
|
||||
console.log('🧪 Running File Examples System Tests...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const test = (name: string, fn: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = fn();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(() => {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
});
|
||||
} else {
|
||||
console.log(`✅ ${name}`);
|
||||
passed++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`❌ ${name}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1: Data structure exists
|
||||
test('File examples data is loaded', () => {
|
||||
if (!sampleFileExamples || sampleFileExamples.length === 0) {
|
||||
throw new Error('No file examples found');
|
||||
}
|
||||
console.log(` Found ${sampleFileExamples.length} groups`);
|
||||
});
|
||||
|
||||
// Test 2: FileExampleManager exists
|
||||
test('FileExampleManager class is available', () => {
|
||||
if (!FileExampleManager) {
|
||||
throw new Error('FileExampleManager not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Sample data has correct structure
|
||||
test('Sample data has correct structure', () => {
|
||||
const group = sampleFileExamples[0];
|
||||
if (!group.groupId || !group.title || !Array.isArray(group.files)) {
|
||||
throw new Error('Invalid group structure');
|
||||
}
|
||||
|
||||
const file = group.files[0];
|
||||
if (!file.id || !file.filename || !file.content || !file.language) {
|
||||
throw new Error('Invalid file structure');
|
||||
}
|
||||
|
||||
// Check for postSlug
|
||||
if (!file.postSlug) {
|
||||
throw new Error('Files missing postSlug property');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Get all groups
|
||||
test('FileExampleManager.getAllGroups() works', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
if (!Array.isArray(groups) || groups.length === 0) {
|
||||
throw new Error('getAllGroups returned invalid result');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: Get specific group
|
||||
test('FileExampleManager.getGroup() works', async () => {
|
||||
const group = await FileExampleManager.getGroup('python-data-processing');
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
if (group.groupId !== 'python-data-processing') {
|
||||
throw new Error('Wrong group returned');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: Search files
|
||||
test('FileExampleManager.searchFiles() works', async () => {
|
||||
const results = await FileExampleManager.searchFiles('python');
|
||||
if (!Array.isArray(results)) {
|
||||
throw new Error('searchFiles returned invalid result');
|
||||
}
|
||||
if (results.length === 0) {
|
||||
throw new Error('No results found for "python"');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7: Get file by ID
|
||||
test('FileExampleManager.getFileExample() works', async () => {
|
||||
const file = await FileExampleManager.getFileExample('python-data-processor');
|
||||
if (!file) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
if (file.id !== 'python-data-processor') {
|
||||
throw new Error('Wrong file returned');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 8: Filter by postSlug
|
||||
test('Filter files by postSlug', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const debuggingFiles = groups.flatMap(g => g.files).filter(f => f.postSlug === 'debugging-tips');
|
||||
|
||||
if (debuggingFiles.length === 0) {
|
||||
throw new Error('No files found for debugging-tips');
|
||||
}
|
||||
|
||||
console.log(` Found ${debuggingFiles.length} files for debugging-tips`);
|
||||
});
|
||||
|
||||
// Test 9: Filter by postSlug and groupId
|
||||
test('Filter files by postSlug and groupId', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const filtered = groups
|
||||
.filter(g => g.groupId === 'python-data-processing')
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f => f.postSlug === 'debugging-tips')
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
throw new Error('No files found for debugging-tips in python-data-processing');
|
||||
}
|
||||
|
||||
console.log(` Found ${filtered[0].files.length} files`);
|
||||
});
|
||||
|
||||
// Test 10: Filter by postSlug and tags
|
||||
test('Filter files by postSlug and tags', async () => {
|
||||
const groups = await FileExampleManager.getAllGroups();
|
||||
const tags = ['architecture', 'design-patterns'];
|
||||
|
||||
const filtered = groups
|
||||
.map(g => ({
|
||||
...g,
|
||||
files: g.files.filter(f =>
|
||||
f.postSlug === 'architecture-patterns' &&
|
||||
f.tags?.some(tag => tags.includes(tag))
|
||||
)
|
||||
}))
|
||||
.filter(g => g.files.length > 0);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
throw new Error('No files found for architecture-patterns with tags');
|
||||
}
|
||||
|
||||
console.log(` Found ${filtered[0].files.length} files`);
|
||||
});
|
||||
|
||||
// Test 11: Download single file
|
||||
test('Download single file', async () => {
|
||||
const result = await FileExampleManager.downloadFile('python-data-processor');
|
||||
if (!result) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
if (!result.filename || !result.content || !result.mimeType) {
|
||||
throw new Error('Invalid download result');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 12: Download multiple files
|
||||
test('Download multiple files', async () => {
|
||||
const files = await FileExampleManager.downloadMultiple(['python-data-processor', 'python-config-example']);
|
||||
if (!Array.isArray(files) || files.length !== 2) {
|
||||
throw new Error('Invalid multiple download result');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 13: Get available tags
|
||||
test('Get available tags', async () => {
|
||||
const tags = await FileExampleManager.getAvailableTags();
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
throw new Error('No tags found');
|
||||
}
|
||||
if (!tags.includes('python') || !tags.includes('architecture')) {
|
||||
throw new Error('Expected tags not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 14: Create new file example
|
||||
test('Create new file example', async () => {
|
||||
const newExample = await FileExampleManager.createFileExample({
|
||||
filename: 'test.py',
|
||||
content: 'print("test")',
|
||||
language: 'python',
|
||||
description: 'Test file',
|
||||
tags: ['test'],
|
||||
postSlug: 'test-post'
|
||||
});
|
||||
|
||||
if (!newExample.id) {
|
||||
throw new Error('New example has no ID');
|
||||
}
|
||||
|
||||
// Verify it was added
|
||||
const retrieved = await FileExampleManager.getFileExample(newExample.id);
|
||||
if (!retrieved || retrieved.filename !== 'test.py') {
|
||||
throw new Error('New example not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 15: Update file example
|
||||
test('Update file example', async () => {
|
||||
const updated = await FileExampleManager.updateFileExample('python-data-processor', {
|
||||
description: 'Updated description'
|
||||
});
|
||||
|
||||
if (!updated || updated.description !== 'Updated description') {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
});
|
||||
|
||||
// Test 16: Delete file example
|
||||
test('Delete file example', async () => {
|
||||
// First create one
|
||||
const created = await FileExampleManager.createFileExample({
|
||||
filename: 'delete-test.py',
|
||||
content: 'test',
|
||||
language: 'python',
|
||||
postSlug: 'test'
|
||||
});
|
||||
|
||||
// Then delete it
|
||||
const deleted = await FileExampleManager.deleteFileExample(created.id);
|
||||
if (!deleted) {
|
||||
throw new Error('Delete failed');
|
||||
}
|
||||
|
||||
// Verify it's gone
|
||||
const retrieved = await FileExampleManager.getFileExample(created.id);
|
||||
if (retrieved) {
|
||||
throw new Error('File still exists after deletion');
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all async tests to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('🎉 All tests passed!');
|
||||
} else {
|
||||
console.log('❌ Some tests failed');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other test files
|
||||
Reference in New Issue
Block a user