@nicnocquee/dataqueue 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
1
+ import path from 'path';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ APP_ROUTER_ROUTE_TEMPLATE,
5
+ CRON_SH_TEMPLATE,
6
+ PAGES_ROUTER_ROUTE_TEMPLATE,
7
+ QUEUE_TEMPLATE,
8
+ detectNextJsAndRouter,
9
+ runInit,
10
+ } from './init-command.js';
11
+
12
+ type VirtualFsState = {
13
+ files: Map<string, string>;
14
+ dirs: Set<string>;
15
+ chmodCalls: Array<{ filePath: string; mode: number }>;
16
+ };
17
+
18
+ /**
19
+ * Builds a fake filesystem API surface compatible with `InitDeps`.
20
+ */
21
+ function createVirtualFs(
22
+ cwd: string,
23
+ initialFiles: Record<string, string> = {},
24
+ initialDirs: string[] = [],
25
+ ) {
26
+ const state: VirtualFsState = {
27
+ files: new Map(),
28
+ dirs: new Set([cwd, ...initialDirs.map((dir) => resolvePath(cwd, dir))]),
29
+ chmodCalls: [],
30
+ };
31
+
32
+ for (const [filePath, content] of Object.entries(initialFiles)) {
33
+ const absolutePath = resolvePath(cwd, filePath);
34
+ state.files.set(absolutePath, content);
35
+ state.dirs.add(path.dirname(absolutePath));
36
+ }
37
+
38
+ return {
39
+ state,
40
+ existsSyncImpl: vi.fn((targetPath: string) => {
41
+ return state.files.has(targetPath) || state.dirs.has(targetPath);
42
+ }),
43
+ mkdirSyncImpl: vi.fn((targetPath: string) => {
44
+ state.dirs.add(targetPath);
45
+ }),
46
+ readFileSyncImpl: vi.fn((targetPath: string) => {
47
+ const content = state.files.get(targetPath);
48
+ if (typeof content !== 'string') {
49
+ throw new Error(`ENOENT: ${targetPath}`);
50
+ }
51
+ return content;
52
+ }),
53
+ writeFileSyncImpl: vi.fn((targetPath: string, content: string) => {
54
+ state.files.set(targetPath, content);
55
+ state.dirs.add(path.dirname(targetPath));
56
+ }),
57
+ chmodSyncImpl: vi.fn((filePath: string, mode: number) => {
58
+ state.chmodCalls.push({ filePath, mode });
59
+ }),
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Resolves a project-relative path to absolute for tests.
65
+ */
66
+ function resolvePath(cwd: string, maybeRelativePath: string): string {
67
+ if (path.isAbsolute(maybeRelativePath)) {
68
+ return maybeRelativePath;
69
+ }
70
+ return path.join(cwd, maybeRelativePath);
71
+ }
72
+
73
+ describe('detectNextJsAndRouter', () => {
74
+ const cwd = '/project';
75
+
76
+ it('throws if package.json is missing', () => {
77
+ const fs = createVirtualFs(cwd);
78
+ expect(() =>
79
+ detectNextJsAndRouter({
80
+ cwd,
81
+ existsSyncImpl: fs.existsSyncImpl as any,
82
+ readFileSyncImpl: fs.readFileSyncImpl as any,
83
+ }),
84
+ ).toThrow('package.json not found in current directory.');
85
+ });
86
+
87
+ it('throws if next dependency is missing', () => {
88
+ const fs = createVirtualFs(cwd, {
89
+ 'package.json': JSON.stringify({ name: 'app' }),
90
+ });
91
+
92
+ expect(() =>
93
+ detectNextJsAndRouter({
94
+ cwd,
95
+ existsSyncImpl: fs.existsSyncImpl as any,
96
+ readFileSyncImpl: fs.readFileSyncImpl as any,
97
+ }),
98
+ ).toThrow(
99
+ "Not a Next.js project. Could not find 'next' in package.json dependencies.",
100
+ );
101
+ });
102
+
103
+ it('detects app router when app exists', () => {
104
+ const fs = createVirtualFs(
105
+ cwd,
106
+ {
107
+ 'package.json': JSON.stringify({
108
+ dependencies: { next: '15.0.0' },
109
+ }),
110
+ },
111
+ ['app'],
112
+ );
113
+
114
+ const result = detectNextJsAndRouter({
115
+ cwd,
116
+ existsSyncImpl: fs.existsSyncImpl as any,
117
+ readFileSyncImpl: fs.readFileSyncImpl as any,
118
+ });
119
+
120
+ expect(result.router).toBe('app');
121
+ expect(result.srcRoot).toBe('.');
122
+ });
123
+
124
+ it('detects pages router when only pages exists', () => {
125
+ const fs = createVirtualFs(
126
+ cwd,
127
+ {
128
+ 'package.json': JSON.stringify({
129
+ devDependencies: { next: '15.0.0' },
130
+ }),
131
+ },
132
+ ['pages'],
133
+ );
134
+
135
+ const result = detectNextJsAndRouter({
136
+ cwd,
137
+ existsSyncImpl: fs.existsSyncImpl as any,
138
+ readFileSyncImpl: fs.readFileSyncImpl as any,
139
+ });
140
+
141
+ expect(result.router).toBe('pages');
142
+ expect(result.srcRoot).toBe('.');
143
+ });
144
+
145
+ it('prefers app router when both app and pages exist', () => {
146
+ const fs = createVirtualFs(
147
+ cwd,
148
+ {
149
+ 'package.json': JSON.stringify({
150
+ dependencies: { next: '15.0.0' },
151
+ }),
152
+ },
153
+ ['app', 'pages'],
154
+ );
155
+
156
+ const result = detectNextJsAndRouter({
157
+ cwd,
158
+ existsSyncImpl: fs.existsSyncImpl as any,
159
+ readFileSyncImpl: fs.readFileSyncImpl as any,
160
+ });
161
+
162
+ expect(result.router).toBe('app');
163
+ });
164
+
165
+ it('uses src as root when src exists', () => {
166
+ const fs = createVirtualFs(
167
+ cwd,
168
+ {
169
+ 'package.json': JSON.stringify({
170
+ dependencies: { next: '15.0.0' },
171
+ }),
172
+ },
173
+ ['src', 'src/pages'],
174
+ );
175
+
176
+ const result = detectNextJsAndRouter({
177
+ cwd,
178
+ existsSyncImpl: fs.existsSyncImpl as any,
179
+ readFileSyncImpl: fs.readFileSyncImpl as any,
180
+ });
181
+
182
+ expect(result.srcRoot).toBe('src');
183
+ expect(result.router).toBe('pages');
184
+ });
185
+
186
+ it('throws when neither app nor pages exists', () => {
187
+ const fs = createVirtualFs(cwd, {
188
+ 'package.json': JSON.stringify({
189
+ dependencies: { next: '15.0.0' },
190
+ }),
191
+ });
192
+
193
+ expect(() =>
194
+ detectNextJsAndRouter({
195
+ cwd,
196
+ existsSyncImpl: fs.existsSyncImpl as any,
197
+ readFileSyncImpl: fs.readFileSyncImpl as any,
198
+ }),
199
+ ).toThrow(
200
+ 'Could not detect Next.js router. Expected either app/ or pages/ directory.',
201
+ );
202
+ });
203
+ });
204
+
205
+ describe('runInit', () => {
206
+ const cwd = '/project';
207
+ let log: ReturnType<typeof vi.fn>;
208
+ let error: ReturnType<typeof vi.fn>;
209
+ let exit: ReturnType<typeof vi.fn>;
210
+
211
+ beforeEach(() => {
212
+ log = vi.fn();
213
+ error = vi.fn();
214
+ exit = vi.fn();
215
+ });
216
+
217
+ it('creates app router files, updates package.json, and exits successfully', () => {
218
+ const fs = createVirtualFs(
219
+ cwd,
220
+ {
221
+ 'package.json': JSON.stringify({
222
+ name: 'app',
223
+ dependencies: { next: '15.0.0' },
224
+ }),
225
+ },
226
+ ['app'],
227
+ );
228
+
229
+ runInit({
230
+ cwd,
231
+ log,
232
+ error,
233
+ exit,
234
+ existsSyncImpl: fs.existsSyncImpl as any,
235
+ mkdirSyncImpl: fs.mkdirSyncImpl as any,
236
+ readFileSyncImpl: fs.readFileSyncImpl as any,
237
+ writeFileSyncImpl: fs.writeFileSyncImpl as any,
238
+ chmodSyncImpl: fs.chmodSyncImpl as any,
239
+ });
240
+
241
+ expect(
242
+ fs.state.files.get(
243
+ resolvePath(cwd, 'app/api/dataqueue/manage/[[...task]]/route.ts'),
244
+ ),
245
+ ).toBe(APP_ROUTER_ROUTE_TEMPLATE);
246
+ expect(fs.state.files.get(resolvePath(cwd, 'lib/dataqueue/queue.ts'))).toBe(
247
+ QUEUE_TEMPLATE,
248
+ );
249
+ expect(fs.state.files.get(resolvePath(cwd, 'cron.sh'))).toBe(
250
+ CRON_SH_TEMPLATE,
251
+ );
252
+ expect(fs.state.chmodCalls).toEqual([
253
+ { filePath: resolvePath(cwd, 'cron.sh'), mode: 0o755 },
254
+ ]);
255
+
256
+ const updatedPackageJson = JSON.parse(
257
+ fs.state.files.get(resolvePath(cwd, 'package.json')) || '{}',
258
+ );
259
+ expect(updatedPackageJson.dependencies['@nicnocquee/dataqueue']).toBe(
260
+ 'latest',
261
+ );
262
+ expect(
263
+ updatedPackageJson.dependencies['@nicnocquee/dataqueue-dashboard'],
264
+ ).toBe('latest');
265
+ expect(updatedPackageJson.dependencies['@nicnocquee/dataqueue-react']).toBe(
266
+ 'latest',
267
+ );
268
+ expect(updatedPackageJson.devDependencies['dotenv-cli']).toBe('latest');
269
+ expect(updatedPackageJson.devDependencies['ts-node']).toBe('latest');
270
+ expect(updatedPackageJson.devDependencies['node-pg-migrate']).toBe(
271
+ 'latest',
272
+ );
273
+ expect(updatedPackageJson.scripts.cron).toBe('bash cron.sh');
274
+ expect(updatedPackageJson.scripts['migrate-dataqueue']).toBe(
275
+ 'dotenv -e .env.local -- dataqueue-cli migrate',
276
+ );
277
+
278
+ expect(log).toHaveBeenCalledWith(
279
+ ' [skipped] pages/api/dataqueue/manage/[[...task]].ts (router not selected)',
280
+ );
281
+ expect(log).toHaveBeenCalledWith(
282
+ "Done! Run your package manager's install command to install new dependencies.",
283
+ );
284
+ expect(error).not.toHaveBeenCalled();
285
+ expect(exit).toHaveBeenCalledWith(0);
286
+ });
287
+
288
+ it('creates pages router file when only pages router exists', () => {
289
+ const fs = createVirtualFs(
290
+ cwd,
291
+ {
292
+ 'package.json': JSON.stringify({
293
+ name: 'app',
294
+ dependencies: { next: '15.0.0' },
295
+ }),
296
+ },
297
+ ['pages'],
298
+ );
299
+
300
+ runInit({
301
+ cwd,
302
+ log,
303
+ error,
304
+ exit,
305
+ existsSyncImpl: fs.existsSyncImpl as any,
306
+ mkdirSyncImpl: fs.mkdirSyncImpl as any,
307
+ readFileSyncImpl: fs.readFileSyncImpl as any,
308
+ writeFileSyncImpl: fs.writeFileSyncImpl as any,
309
+ chmodSyncImpl: fs.chmodSyncImpl as any,
310
+ });
311
+
312
+ expect(
313
+ fs.state.files.get(
314
+ resolvePath(cwd, 'pages/api/dataqueue/manage/[[...task]].ts'),
315
+ ),
316
+ ).toBe(PAGES_ROUTER_ROUTE_TEMPLATE);
317
+ expect(log).toHaveBeenCalledWith(
318
+ ' [skipped] app/api/dataqueue/manage/[[...task]]/route.ts (router not selected)',
319
+ );
320
+ expect(exit).toHaveBeenCalledWith(0);
321
+ });
322
+
323
+ it('skips existing files and existing package entries', () => {
324
+ const existingRoute = '/* existing */';
325
+ const existingCron = '#!/bin/bash\n# existing';
326
+ const existingQueue = '// existing queue';
327
+ const fs = createVirtualFs(
328
+ cwd,
329
+ {
330
+ 'package.json': JSON.stringify({
331
+ dependencies: {
332
+ next: '15.0.0',
333
+ '@nicnocquee/dataqueue': '^1.0.0',
334
+ },
335
+ devDependencies: {
336
+ 'dotenv-cli': '^8.0.0',
337
+ },
338
+ scripts: {
339
+ cron: 'bash cron.sh',
340
+ },
341
+ }),
342
+ 'app/api/dataqueue/manage/[[...task]]/route.ts': existingRoute,
343
+ 'cron.sh': existingCron,
344
+ 'lib/dataqueue/queue.ts': existingQueue,
345
+ },
346
+ ['app'],
347
+ );
348
+
349
+ runInit({
350
+ cwd,
351
+ log,
352
+ error,
353
+ exit,
354
+ existsSyncImpl: fs.existsSyncImpl as any,
355
+ mkdirSyncImpl: fs.mkdirSyncImpl as any,
356
+ readFileSyncImpl: fs.readFileSyncImpl as any,
357
+ writeFileSyncImpl: fs.writeFileSyncImpl as any,
358
+ chmodSyncImpl: fs.chmodSyncImpl as any,
359
+ });
360
+
361
+ expect(
362
+ fs.state.files.get(
363
+ resolvePath(cwd, 'app/api/dataqueue/manage/[[...task]]/route.ts'),
364
+ ),
365
+ ).toBe(existingRoute);
366
+ expect(fs.state.files.get(resolvePath(cwd, 'cron.sh'))).toBe(existingCron);
367
+ expect(fs.state.files.get(resolvePath(cwd, 'lib/dataqueue/queue.ts'))).toBe(
368
+ existingQueue,
369
+ );
370
+
371
+ const updatedPackageJson = JSON.parse(
372
+ fs.state.files.get(resolvePath(cwd, 'package.json')) || '{}',
373
+ );
374
+ expect(updatedPackageJson.dependencies['@nicnocquee/dataqueue']).toBe(
375
+ '^1.0.0',
376
+ );
377
+ expect(updatedPackageJson.scripts.cron).toBe('bash cron.sh');
378
+ expect(updatedPackageJson.scripts['migrate-dataqueue']).toBe(
379
+ 'dotenv -e .env.local -- dataqueue-cli migrate',
380
+ );
381
+ expect(log).toHaveBeenCalledWith(
382
+ ' [skipped] dependency @nicnocquee/dataqueue (already exists)',
383
+ );
384
+ expect(log).toHaveBeenCalledWith(
385
+ ' [skipped] script "cron" (already exists)',
386
+ );
387
+ expect(exit).toHaveBeenCalledWith(0);
388
+ });
389
+
390
+ it('works for a monorepo sub-app by using cwd package.json', () => {
391
+ const subAppCwd = '/repo/apps/web';
392
+ const fs = createVirtualFs(
393
+ subAppCwd,
394
+ {
395
+ 'package.json': JSON.stringify({
396
+ dependencies: { next: '15.0.0' },
397
+ }),
398
+ },
399
+ ['app'],
400
+ );
401
+
402
+ runInit({
403
+ cwd: subAppCwd,
404
+ log,
405
+ error,
406
+ exit,
407
+ existsSyncImpl: fs.existsSyncImpl as any,
408
+ mkdirSyncImpl: fs.mkdirSyncImpl as any,
409
+ readFileSyncImpl: fs.readFileSyncImpl as any,
410
+ writeFileSyncImpl: fs.writeFileSyncImpl as any,
411
+ chmodSyncImpl: fs.chmodSyncImpl as any,
412
+ });
413
+
414
+ expect(
415
+ fs.state.files.has(
416
+ resolvePath(subAppCwd, 'app/api/dataqueue/manage/[[...task]]/route.ts'),
417
+ ),
418
+ ).toBe(true);
419
+ expect(exit).toHaveBeenCalledWith(0);
420
+ });
421
+
422
+ it('logs an error and exits with code 1 for invalid package.json', () => {
423
+ const fs = createVirtualFs(
424
+ cwd,
425
+ {
426
+ 'package.json': '{invalid json',
427
+ },
428
+ ['app'],
429
+ );
430
+
431
+ runInit({
432
+ cwd,
433
+ log,
434
+ error,
435
+ exit,
436
+ existsSyncImpl: fs.existsSyncImpl as any,
437
+ mkdirSyncImpl: fs.mkdirSyncImpl as any,
438
+ readFileSyncImpl: fs.readFileSyncImpl as any,
439
+ writeFileSyncImpl: fs.writeFileSyncImpl as any,
440
+ chmodSyncImpl: fs.chmodSyncImpl as any,
441
+ });
442
+
443
+ expect(error).toHaveBeenCalledTimes(1);
444
+ expect(String(error.mock.calls[0][0])).toContain(
445
+ 'Failed to parse package.json',
446
+ );
447
+ expect(exit).toHaveBeenCalledWith(1);
448
+ });
449
+ });