|
| 1 | +import express from 'express' |
| 2 | +import fs from 'fs' |
| 3 | +import path from 'path' |
| 4 | + |
| 5 | +/** |
| 6 | + * Internal API test server to replace json-server dependency |
| 7 | + * Provides REST API endpoints for testing CodeceptJS helpers |
| 8 | + */ |
| 9 | +class TestServer { |
| 10 | + constructor(config = {}) { |
| 11 | + this.app = express() |
| 12 | + this.server = null |
| 13 | + this.port = config.port || 8010 |
| 14 | + this.host = config.host || 'localhost' |
| 15 | + this.dbFile = config.dbFile || path.join(__dirname, '../test/data/rest/db.json') |
| 16 | + this.readOnly = config.readOnly || false |
| 17 | + this.lastModified = null |
| 18 | + this.data = this.loadData() |
| 19 | + |
| 20 | + this.setupMiddleware() |
| 21 | + this.setupRoutes() |
| 22 | + this.setupFileWatcher() |
| 23 | + } |
| 24 | + |
| 25 | + loadData() { |
| 26 | + try { |
| 27 | + const content = fs.readFileSync(this.dbFile, 'utf8') |
| 28 | + const data = JSON.parse(content) |
| 29 | + // Update lastModified time when loading data |
| 30 | + if (fs.existsSync(this.dbFile)) { |
| 31 | + this.lastModified = fs.statSync(this.dbFile).mtime |
| 32 | + } |
| 33 | + console.log('[Data Load] Loaded data from file:', JSON.stringify(data)) |
| 34 | + return data |
| 35 | + } catch (err) { |
| 36 | + console.warn(`[Data Load] Could not load data file ${this.dbFile}:`, err.message) |
| 37 | + console.log('[Data Load] Using fallback default data') |
| 38 | + return { |
| 39 | + posts: [{ id: 1, title: 'json-server', author: 'davert' }], |
| 40 | + user: { name: 'john', password: '123456' }, |
| 41 | + } |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + reloadData() { |
| 46 | + console.log('[Reload] Reloading data from file...') |
| 47 | + this.data = this.loadData() |
| 48 | + console.log('[Reload] Data reloaded successfully') |
| 49 | + return this.data |
| 50 | + } |
| 51 | + |
| 52 | + saveData() { |
| 53 | + if (this.readOnly) { |
| 54 | + console.log('[Save] Skipping save - running in read-only mode') |
| 55 | + return |
| 56 | + } |
| 57 | + try { |
| 58 | + fs.writeFileSync(this.dbFile, JSON.stringify(this.data, null, 2)) |
| 59 | + console.log('[Save] Data saved to file') |
| 60 | + // Force update modification time to ensure auto-reload works |
| 61 | + const now = new Date() |
| 62 | + fs.utimesSync(this.dbFile, now, now) |
| 63 | + this.lastModified = now |
| 64 | + console.log('[Save] File modification time updated') |
| 65 | + } catch (err) { |
| 66 | + console.warn(`[Save] Could not save data file ${this.dbFile}:`, err.message) |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + setupMiddleware() { |
| 71 | + // Parse JSON bodies |
| 72 | + this.app.use(express.json()) |
| 73 | + |
| 74 | + // Parse URL-encoded bodies |
| 75 | + this.app.use(express.urlencoded({ extended: true })) |
| 76 | + |
| 77 | + // CORS support |
| 78 | + this.app.use((req, res, next) => { |
| 79 | + res.header('Access-Control-Allow-Origin', '*') |
| 80 | + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') |
| 81 | + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Test') |
| 82 | + |
| 83 | + if (req.method === 'OPTIONS') { |
| 84 | + res.status(200).end() |
| 85 | + return |
| 86 | + } |
| 87 | + next() |
| 88 | + }) |
| 89 | + |
| 90 | + // Auto-reload middleware - check if file changed before each request |
| 91 | + this.app.use((req, res, next) => { |
| 92 | + try { |
| 93 | + if (fs.existsSync(this.dbFile)) { |
| 94 | + const stats = fs.statSync(this.dbFile) |
| 95 | + if (!this.lastModified || stats.mtime > this.lastModified) { |
| 96 | + console.log(`[Auto-reload] Database file changed (${this.dbFile}), reloading data...`) |
| 97 | + console.log(`[Auto-reload] Old mtime: ${this.lastModified}, New mtime: ${stats.mtime}`) |
| 98 | + this.reloadData() |
| 99 | + this.lastModified = stats.mtime |
| 100 | + console.log(`[Auto-reload] Data reloaded, user name is now: ${this.data.user?.name}`) |
| 101 | + } |
| 102 | + } |
| 103 | + } catch (err) { |
| 104 | + console.warn('[Auto-reload] Error checking file modification time:', err.message) |
| 105 | + } |
| 106 | + next() |
| 107 | + }) |
| 108 | + |
| 109 | + // Logging middleware |
| 110 | + this.app.use((req, res, next) => { |
| 111 | + console.log(`${req.method} ${req.path}`) |
| 112 | + next() |
| 113 | + }) |
| 114 | + } |
| 115 | + |
| 116 | + setupRoutes() { |
| 117 | + // Reload endpoint (for testing) |
| 118 | + this.app.post('/_reload', (req, res) => { |
| 119 | + this.reloadData() |
| 120 | + res.json({ message: 'Data reloaded', data: this.data }) |
| 121 | + }) |
| 122 | + |
| 123 | + // Headers endpoint (for header testing) |
| 124 | + this.app.get('/headers', (req, res) => { |
| 125 | + res.json(req.headers) |
| 126 | + }) |
| 127 | + |
| 128 | + this.app.post('/headers', (req, res) => { |
| 129 | + res.json(req.headers) |
| 130 | + }) |
| 131 | + |
| 132 | + // User endpoints |
| 133 | + this.app.get('/user', (req, res) => { |
| 134 | + console.log(`[GET /user] Serving user data: ${JSON.stringify(this.data.user)}`) |
| 135 | + res.json(this.data.user) |
| 136 | + }) |
| 137 | + |
| 138 | + this.app.post('/user', (req, res) => { |
| 139 | + this.data.user = { ...this.data.user, ...req.body } |
| 140 | + this.saveData() |
| 141 | + res.status(201).json(this.data.user) |
| 142 | + }) |
| 143 | + |
| 144 | + this.app.patch('/user', (req, res) => { |
| 145 | + this.data.user = { ...this.data.user, ...req.body } |
| 146 | + this.saveData() |
| 147 | + res.json(this.data.user) |
| 148 | + }) |
| 149 | + |
| 150 | + this.app.put('/user', (req, res) => { |
| 151 | + this.data.user = req.body |
| 152 | + this.saveData() |
| 153 | + res.json(this.data.user) |
| 154 | + }) |
| 155 | + |
| 156 | + // Posts endpoints |
| 157 | + this.app.get('/posts', (req, res) => { |
| 158 | + res.json(this.data.posts) |
| 159 | + }) |
| 160 | + |
| 161 | + this.app.get('/posts/:id', (req, res) => { |
| 162 | + const id = parseInt(req.params.id) |
| 163 | + const post = this.data.posts.find(p => p.id === id) |
| 164 | + |
| 165 | + if (!post) { |
| 166 | + // Return empty object instead of 404 for json-server compatibility |
| 167 | + return res.json({}) |
| 168 | + } |
| 169 | + |
| 170 | + res.json(post) |
| 171 | + }) |
| 172 | + |
| 173 | + this.app.post('/posts', (req, res) => { |
| 174 | + const newId = Math.max(...this.data.posts.map(p => p.id || 0)) + 1 |
| 175 | + const newPost = { id: newId, ...req.body } |
| 176 | + |
| 177 | + this.data.posts.push(newPost) |
| 178 | + this.saveData() |
| 179 | + res.status(201).json(newPost) |
| 180 | + }) |
| 181 | + |
| 182 | + this.app.put('/posts/:id', (req, res) => { |
| 183 | + const id = parseInt(req.params.id) |
| 184 | + const postIndex = this.data.posts.findIndex(p => p.id === id) |
| 185 | + |
| 186 | + if (postIndex === -1) { |
| 187 | + return res.status(404).json({ error: 'Post not found' }) |
| 188 | + } |
| 189 | + |
| 190 | + this.data.posts[postIndex] = { id, ...req.body } |
| 191 | + this.saveData() |
| 192 | + res.json(this.data.posts[postIndex]) |
| 193 | + }) |
| 194 | + |
| 195 | + this.app.patch('/posts/:id', (req, res) => { |
| 196 | + const id = parseInt(req.params.id) |
| 197 | + const postIndex = this.data.posts.findIndex(p => p.id === id) |
| 198 | + |
| 199 | + if (postIndex === -1) { |
| 200 | + return res.status(404).json({ error: 'Post not found' }) |
| 201 | + } |
| 202 | + |
| 203 | + this.data.posts[postIndex] = { ...this.data.posts[postIndex], ...req.body } |
| 204 | + this.saveData() |
| 205 | + res.json(this.data.posts[postIndex]) |
| 206 | + }) |
| 207 | + |
| 208 | + this.app.delete('/posts/:id', (req, res) => { |
| 209 | + const id = parseInt(req.params.id) |
| 210 | + const postIndex = this.data.posts.findIndex(p => p.id === id) |
| 211 | + |
| 212 | + if (postIndex === -1) { |
| 213 | + return res.status(404).json({ error: 'Post not found' }) |
| 214 | + } |
| 215 | + |
| 216 | + const deletedPost = this.data.posts.splice(postIndex, 1)[0] |
| 217 | + this.saveData() |
| 218 | + res.json(deletedPost) |
| 219 | + }) |
| 220 | + |
| 221 | + // File upload endpoint (basic implementation) |
| 222 | + this.app.post('/upload', (req, res) => { |
| 223 | + // Simple upload simulation - for more complex file uploads, |
| 224 | + // multer would be needed but basic tests should work |
| 225 | + res.json({ |
| 226 | + message: 'File upload endpoint available', |
| 227 | + headers: req.headers, |
| 228 | + body: req.body, |
| 229 | + }) |
| 230 | + }) |
| 231 | + |
| 232 | + // Comments endpoints (for ApiDataFactory tests) |
| 233 | + this.app.get('/comments', (req, res) => { |
| 234 | + res.json(this.data.comments || []) |
| 235 | + }) |
| 236 | + |
| 237 | + this.app.post('/comments', (req, res) => { |
| 238 | + if (!this.data.comments) this.data.comments = [] |
| 239 | + const newId = Math.max(...this.data.comments.map(c => c.id || 0), 0) + 1 |
| 240 | + const newComment = { id: newId, ...req.body } |
| 241 | + |
| 242 | + this.data.comments.push(newComment) |
| 243 | + this.saveData() |
| 244 | + res.status(201).json(newComment) |
| 245 | + }) |
| 246 | + |
| 247 | + this.app.delete('/comments/:id', (req, res) => { |
| 248 | + if (!this.data.comments) this.data.comments = [] |
| 249 | + const id = parseInt(req.params.id) |
| 250 | + const commentIndex = this.data.comments.findIndex(c => c.id === id) |
| 251 | + |
| 252 | + if (commentIndex === -1) { |
| 253 | + return res.status(404).json({ error: 'Comment not found' }) |
| 254 | + } |
| 255 | + |
| 256 | + const deletedComment = this.data.comments.splice(commentIndex, 1)[0] |
| 257 | + this.saveData() |
| 258 | + res.json(deletedComment) |
| 259 | + }) |
| 260 | + |
| 261 | + // Generic catch-all for other endpoints |
| 262 | + this.app.use((req, res) => { |
| 263 | + res.status(404).json({ error: 'Endpoint not found' }) |
| 264 | + }) |
| 265 | + } |
| 266 | + |
| 267 | + setupFileWatcher() { |
| 268 | + if (fs.existsSync(this.dbFile)) { |
| 269 | + fs.watchFile(this.dbFile, (current, previous) => { |
| 270 | + if (current.mtime !== previous.mtime) { |
| 271 | + console.log('Database file changed, reloading data...') |
| 272 | + this.reloadData() |
| 273 | + } |
| 274 | + }) |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + start() { |
| 279 | + return new Promise((resolve, reject) => { |
| 280 | + this.server = this.app.listen(this.port, this.host, err => { |
| 281 | + if (err) { |
| 282 | + reject(err) |
| 283 | + } else { |
| 284 | + console.log(`Test server running on http://${this.host}:${this.port}`) |
| 285 | + resolve(this.server) |
| 286 | + } |
| 287 | + }) |
| 288 | + }) |
| 289 | + } |
| 290 | + |
| 291 | + stop() { |
| 292 | + return new Promise(resolve => { |
| 293 | + if (this.server) { |
| 294 | + this.server.close(() => { |
| 295 | + console.log('Test server stopped') |
| 296 | + resolve() |
| 297 | + }) |
| 298 | + } else { |
| 299 | + resolve() |
| 300 | + } |
| 301 | + }) |
| 302 | + } |
| 303 | +} |
| 304 | + |
| 305 | +export default TestServer |
| 306 | + |
| 307 | +// CLI usage - Import meta for ESM |
| 308 | +import { fileURLToPath } from 'url' |
| 309 | +import { dirname } from 'path' |
| 310 | + |
| 311 | +const __filename = fileURLToPath(import.meta.url) |
| 312 | +const __dirname = dirname(__filename) |
| 313 | + |
| 314 | +if (import.meta.url === `file://${process.argv[1]}`) { |
| 315 | + const config = { |
| 316 | + port: process.env.PORT || 8010, |
| 317 | + host: process.env.HOST || '0.0.0.0', |
| 318 | + dbFile: process.argv[2] || path.join(__dirname, '../test/data/rest/db.json'), |
| 319 | + } |
| 320 | + |
| 321 | + const server = new TestServer(config) |
| 322 | + server.start().catch(console.error) |
| 323 | + |
| 324 | + // Graceful shutdown |
| 325 | + process.on('SIGINT', () => { |
| 326 | + console.log('\nShutting down test server...') |
| 327 | + server.stop().then(() => process.exit(0)) |
| 328 | + }) |
| 329 | + |
| 330 | + process.on('SIGTERM', () => { |
| 331 | + console.log('\nShutting down test server...') |
| 332 | + server.stop().then(() => process.exit(0)) |
| 333 | + }) |
| 334 | +} |
0 commit comments