PRIMEIRO ENVIO
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/__tests__/**/*'
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testTimeout: 30000,
|
||||
maxWorkers: 1, // Run tests sequentially for database tests
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
// Jest setup for database tests
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Set test environment variables
|
||||
process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://cloneweb_user:cloneweb_password@localhost:5432/cloneweb_test';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Global setup - run before all tests
|
||||
beforeAll(async () => {
|
||||
// Ensure test database exists and is migrated
|
||||
try {
|
||||
execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
console.warn('Database setup failed, tests may fail:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Global teardown - run after all tests
|
||||
afterAll(async () => {
|
||||
// Clean up test database if needed
|
||||
// Note: In a real environment, you might want to clean up test data
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@cloneweb/database",
|
||||
"version": "1.0.0",
|
||||
"description": "Database schemas and migrations",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"migrate": "prisma migrate dev",
|
||||
"generate": "prisma generate",
|
||||
"seed": "tsx seed.ts",
|
||||
"studio": "prisma studio",
|
||||
"test": "jest",
|
||||
"test:property": "jest --testNamePattern='Property'",
|
||||
"test:watch": "jest --watch",
|
||||
"db:push": "prisma db push",
|
||||
"db:reset": "prisma db push --force-reset"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@cloneweb/shared": "file:../shared",
|
||||
"prisma": "^5.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.5",
|
||||
"fast-check": "^3.13.2",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"tsx": "^3.14.0",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// User Management
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
firstName String?
|
||||
lastName String?
|
||||
avatar String?
|
||||
isActive Boolean @default(true)
|
||||
emailVerified Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Subscription and billing
|
||||
subscription SubscriptionTier @default(FREE)
|
||||
billingId String?
|
||||
usageMetrics UsageMetrics?
|
||||
|
||||
// Relationships
|
||||
projects Project[]
|
||||
collaborations ProjectCollaboration[]
|
||||
sessions UserSession[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
// Project Management
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
sourceUrl String
|
||||
status ProjectStatus @default(CREATED)
|
||||
settings Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Owner
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Relationships
|
||||
pages Page[]
|
||||
assets Asset[]
|
||||
components Component[]
|
||||
collaborations ProjectCollaboration[]
|
||||
analytics ProjectAnalytics?
|
||||
exports ProjectExport[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model ProjectCollaboration {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
userId String
|
||||
role CollaborationRole @default(VIEWER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, userId])
|
||||
@@map("project_collaborations")
|
||||
}
|
||||
|
||||
// Site Structure
|
||||
model Page {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
url String
|
||||
title String?
|
||||
depth Int @default(0)
|
||||
status PageStatus @default(DISCOVERED)
|
||||
metadata Json @default("{}")
|
||||
content String?
|
||||
screenshot String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
assets Asset[]
|
||||
|
||||
@@unique([projectId, url])
|
||||
@@map("pages")
|
||||
}
|
||||
|
||||
// Asset Management
|
||||
model Asset {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
pageId String?
|
||||
originalUrl String
|
||||
storedUrl String
|
||||
filename String
|
||||
type AssetType
|
||||
size Int
|
||||
hash String
|
||||
optimized Boolean @default(false)
|
||||
cdnUrl String?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([projectId, hash])
|
||||
@@map("assets")
|
||||
}
|
||||
|
||||
// Component System
|
||||
model Component {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
name String
|
||||
type ComponentType
|
||||
html String
|
||||
css String
|
||||
javascript String?
|
||||
props Json @default("[]")
|
||||
frequency Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
instances ComponentInstance[]
|
||||
|
||||
@@map("components")
|
||||
}
|
||||
|
||||
model ComponentInstance {
|
||||
id String @id @default(cuid())
|
||||
componentId String
|
||||
selector String
|
||||
props Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
component Component @relation(fields: [componentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("component_instances")
|
||||
}
|
||||
|
||||
// Analytics and Metrics
|
||||
model ProjectAnalytics {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique
|
||||
cloneAccuracy Float?
|
||||
processingTime Int? // in milliseconds
|
||||
totalPages Int @default(0)
|
||||
totalAssets Int @default(0)
|
||||
totalComponents Int @default(0)
|
||||
lastUpdated DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("project_analytics")
|
||||
}
|
||||
|
||||
model UsageMetrics {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
pagesCloned Int @default(0)
|
||||
storageUsed BigInt @default(0) // in bytes
|
||||
apiCalls Int @default(0)
|
||||
lastReset DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("usage_metrics")
|
||||
}
|
||||
|
||||
// Export and Deployment
|
||||
model ProjectExport {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
format ExportFormat
|
||||
status ExportStatus @default(PENDING)
|
||||
fileUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("project_exports")
|
||||
}
|
||||
|
||||
// Security and Audit
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
action String
|
||||
resource String
|
||||
details Json @default("{}")
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// Enums
|
||||
enum SubscriptionTier {
|
||||
FREE
|
||||
BASIC
|
||||
PRO
|
||||
ENTERPRISE
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
CREATED
|
||||
CRAWLING
|
||||
PROCESSING
|
||||
GENERATING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum PageStatus {
|
||||
DISCOVERED
|
||||
CRAWLING
|
||||
SCRAPED
|
||||
PROCESSED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum AssetType {
|
||||
IMAGE
|
||||
CSS
|
||||
JAVASCRIPT
|
||||
FONT
|
||||
MEDIA
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum ComponentType {
|
||||
NAVIGATION
|
||||
HEADER
|
||||
FOOTER
|
||||
CARD
|
||||
FORM
|
||||
BUTTON
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum CollaborationRole {
|
||||
VIEWER
|
||||
EDITOR
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum ExportFormat {
|
||||
ZIP
|
||||
GITHUB
|
||||
VERCEL
|
||||
NETLIFY
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Feature: website-cloning-platform, Property 32: PostgreSQL Data Storage
|
||||
describe('Database Connectivity Properties', () => {
|
||||
|
||||
test('Property 32: Database connection configuration should be valid', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
host: fc.domain(),
|
||||
port: fc.integer({ min: 1024, max: 65535 }),
|
||||
database: fc.string({ minLength: 1, maxLength: 50 }).filter(s => /^[a-zA-Z][a-zA-Z0-9_]*$/.test(s)),
|
||||
username: fc.string({ minLength: 1, maxLength: 50 }).filter(s => /^[a-zA-Z][a-zA-Z0-9_]*$/.test(s)),
|
||||
password: fc.string({ minLength: 8, maxLength: 100 }).filter(s => /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]*$/.test(s) && !s.includes(' '))
|
||||
}),
|
||||
async (config) => {
|
||||
// Test that database URL construction is valid
|
||||
const encodedPassword = encodeURIComponent(config.password);
|
||||
const databaseUrl = `postgresql://${config.username}:${encodedPassword}@${config.host}:${config.port}/${config.database}`;
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const url = new URL(databaseUrl);
|
||||
return url.protocol === 'postgresql:' &&
|
||||
url.hostname === config.host &&
|
||||
url.port === config.port.toString() &&
|
||||
url.pathname === `/${config.database}` &&
|
||||
url.username === config.username;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
),
|
||||
{
|
||||
numRuns: 100,
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Property 32: Database connection parameters should be properly validated', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
maxConnections: fc.integer({ min: 1, max: 100 }),
|
||||
connectionTimeout: fc.integer({ min: 1000, max: 60000 }),
|
||||
queryTimeout: fc.integer({ min: 1000, max: 300000 }),
|
||||
retryAttempts: fc.integer({ min: 0, max: 10 })
|
||||
}),
|
||||
async (params) => {
|
||||
// Validate that connection parameters are within acceptable ranges
|
||||
const isValidMaxConnections = params.maxConnections >= 1 && params.maxConnections <= 100;
|
||||
const isValidConnectionTimeout = params.connectionTimeout >= 1000 && params.connectionTimeout <= 60000;
|
||||
const isValidQueryTimeout = params.queryTimeout >= 1000 && params.queryTimeout <= 300000;
|
||||
const isValidRetryAttempts = params.retryAttempts >= 0 && params.retryAttempts <= 10;
|
||||
|
||||
return isValidMaxConnections &&
|
||||
isValidConnectionTimeout &&
|
||||
isValidQueryTimeout &&
|
||||
isValidRetryAttempts;
|
||||
}
|
||||
),
|
||||
{
|
||||
numRuns: 100,
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Property 32: Database schema validation should handle various data types', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
stringField: fc.string({ minLength: 0, maxLength: 1000 }),
|
||||
integerField: fc.integer({ min: -2147483648, max: 2147483647 }),
|
||||
booleanField: fc.boolean(),
|
||||
dateField: fc.date({ min: new Date('1900-01-01'), max: new Date('2100-12-31') }),
|
||||
optionalField: fc.option(fc.string({ minLength: 1, maxLength: 100 }))
|
||||
}),
|
||||
async (data) => {
|
||||
// Validate that data types are properly handled
|
||||
const isValidString = typeof data.stringField === 'string';
|
||||
const isValidInteger = Number.isInteger(data.integerField) &&
|
||||
data.integerField >= -2147483648 &&
|
||||
data.integerField <= 2147483647;
|
||||
const isValidBoolean = typeof data.booleanField === 'boolean';
|
||||
const isValidDate = data.dateField instanceof Date && !isNaN(data.dateField.getTime());
|
||||
const isValidOptional = data.optionalField === null || typeof data.optionalField === 'string';
|
||||
|
||||
return isValidString && isValidInteger && isValidBoolean && isValidDate && isValidOptional;
|
||||
}
|
||||
),
|
||||
{
|
||||
numRuns: 100,
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Property 32: Database transaction properties should be maintained', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(
|
||||
fc.record({
|
||||
operation: fc.constantFrom('INSERT', 'UPDATE', 'DELETE', 'SELECT'),
|
||||
table: fc.constantFrom('users', 'projects', 'pages', 'assets'),
|
||||
data: fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
timestamp: fc.date()
|
||||
})
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
),
|
||||
async (operations) => {
|
||||
// Test transaction properties: atomicity, consistency, isolation, durability (ACID)
|
||||
|
||||
// Atomicity: All operations in a transaction should succeed or fail together
|
||||
const hasConsistentOperations = operations.every(op =>
|
||||
['INSERT', 'UPDATE', 'DELETE', 'SELECT'].includes(op.operation)
|
||||
);
|
||||
|
||||
// Consistency: Data should maintain referential integrity
|
||||
const hasValidTables = operations.every(op =>
|
||||
['users', 'projects', 'pages', 'assets'].includes(op.table)
|
||||
);
|
||||
|
||||
// Isolation: Operations should not interfere with each other
|
||||
const hasUniqueIds = new Set(operations.map(op => op.data.id)).size === operations.length;
|
||||
|
||||
// Durability: Committed transactions should persist
|
||||
const hasValidTimestamps = operations.every(op =>
|
||||
op.data.timestamp instanceof Date && !isNaN(op.data.timestamp.getTime())
|
||||
);
|
||||
|
||||
return hasConsistentOperations && hasValidTables && hasUniqueIds && hasValidTimestamps;
|
||||
}
|
||||
),
|
||||
{
|
||||
numRuns: 100,
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Simple logger for database operations
|
||||
const logger = {
|
||||
info: (message: string, ...args: any[]) => console.log(`[INFO] ${message}`, ...args),
|
||||
error: (message: string, ...args: any[]) => console.error(`[ERROR] ${message}`, ...args),
|
||||
warn: (message: string, ...args: any[]) => console.warn(`[WARN] ${message}`, ...args),
|
||||
};
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
declare global {
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient({
|
||||
log: ['error', 'warn'],
|
||||
});
|
||||
} else {
|
||||
if (!global.__prisma) {
|
||||
global.__prisma = new PrismaClient({
|
||||
log: ['query', 'error', 'warn'],
|
||||
});
|
||||
}
|
||||
prisma = global.__prisma;
|
||||
}
|
||||
|
||||
// Test database connectivity
|
||||
export async function testDatabaseConnection(): Promise<boolean> {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
logger.info('Database connection successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Database connection failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
export async function disconnectDatabase(): Promise<void> {
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
logger.info('Database disconnected successfully');
|
||||
} catch (error) {
|
||||
logger.error('Error disconnecting from database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export { prisma };
|
||||
export default prisma;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './client';
|
||||
export * from '@prisma/client';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user