nca-ai-cms-astro-plugin 1.0.17 → 1.0.18
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.
- package/package.json +1 -1
- package/src/api/db/upload.test.ts +173 -0
- package/src/api/db/upload.ts +13 -0
- package/update.md +15 -0
package/package.json
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
const mockClose = vi.fn();
|
|
5
|
+
const mockReconnect = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock('astro:db', () => ({
|
|
8
|
+
db: {
|
|
9
|
+
$client: {
|
|
10
|
+
close: mockClose,
|
|
11
|
+
reconnect: mockReconnect,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('fs/promises', () => ({
|
|
17
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
copyFile: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// SQLite file header
|
|
22
|
+
const SQLITE_HEADER = Buffer.from('SQLite format 3\0');
|
|
23
|
+
|
|
24
|
+
function createSqliteBuffer(size = 4096): Buffer {
|
|
25
|
+
const buf = Buffer.alloc(size);
|
|
26
|
+
SQLITE_HEADER.copy(buf);
|
|
27
|
+
return buf;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createFormDataRequest(file: Buffer, fieldName = 'database'): Request {
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append(fieldName, new Blob([file]), 'test.db');
|
|
33
|
+
return new Request('http://localhost/api/db/upload', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
body: formData,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createOctetStreamRequest(file: Buffer): Request {
|
|
40
|
+
return new Request('http://localhost/api/db/upload', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
43
|
+
body: file,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('DB Upload API', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
process.env.ASTRO_DATABASE_FILE = '.astro/content.db';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts a valid SQLite file via multipart/form-data', async () => {
|
|
54
|
+
const { POST } = await import('./upload.js');
|
|
55
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
56
|
+
|
|
57
|
+
const response = await POST({ request } as any);
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
|
|
60
|
+
expect(response.status).toBe(200);
|
|
61
|
+
expect(data.success).toBe(true);
|
|
62
|
+
expect(data.size).toBeGreaterThan(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts a valid SQLite file via application/octet-stream', async () => {
|
|
66
|
+
const { POST } = await import('./upload.js');
|
|
67
|
+
const request = createOctetStreamRequest(createSqliteBuffer());
|
|
68
|
+
|
|
69
|
+
const response = await POST({ request } as any);
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
|
|
72
|
+
expect(response.status).toBe(200);
|
|
73
|
+
expect(data.success).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects non-SQLite files', async () => {
|
|
77
|
+
const { POST } = await import('./upload.js');
|
|
78
|
+
const badFile = Buffer.from('not a sqlite file');
|
|
79
|
+
const request = createFormDataRequest(badFile);
|
|
80
|
+
|
|
81
|
+
const response = await POST({ request } as any);
|
|
82
|
+
const data = await response.json();
|
|
83
|
+
|
|
84
|
+
expect(response.status).toBe(400);
|
|
85
|
+
expect(data.error).toContain('not a SQLite database');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects missing database field', async () => {
|
|
89
|
+
const { POST } = await import('./upload.js');
|
|
90
|
+
const formData = new FormData();
|
|
91
|
+
formData.append('wrongfield', new Blob([createSqliteBuffer()]), 'test.db');
|
|
92
|
+
const request = new Request('http://localhost/api/db/upload', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body: formData,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const response = await POST({ request } as any);
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
|
|
100
|
+
expect(response.status).toBe(400);
|
|
101
|
+
expect(data.error).toContain('No database file');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects invalid content type', async () => {
|
|
105
|
+
const { POST } = await import('./upload.js');
|
|
106
|
+
const request = new Request('http://localhost/api/db/upload', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
109
|
+
body: 'hello',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const response = await POST({ request } as any);
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
|
|
115
|
+
expect(response.status).toBe(400);
|
|
116
|
+
expect(data.error).toContain('Invalid content type');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('creates a backup before writing', async () => {
|
|
120
|
+
const { POST } = await import('./upload.js');
|
|
121
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
122
|
+
|
|
123
|
+
await POST({ request } as any);
|
|
124
|
+
|
|
125
|
+
expect(fs.copyFile).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reconnects the DB client after successful upload', async () => {
|
|
129
|
+
const { POST } = await import('./upload.js');
|
|
130
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
131
|
+
|
|
132
|
+
await POST({ request } as any);
|
|
133
|
+
|
|
134
|
+
expect(mockClose).toHaveBeenCalledOnce();
|
|
135
|
+
expect(mockReconnect).toHaveBeenCalledOnce();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects oversized file via multipart/form-data', async () => {
|
|
139
|
+
const { POST } = await import('./upload.js');
|
|
140
|
+
const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
|
|
141
|
+
const request = createFormDataRequest(oversized);
|
|
142
|
+
|
|
143
|
+
const response = await POST({ request } as any);
|
|
144
|
+
const data = await response.json();
|
|
145
|
+
|
|
146
|
+
expect(response.status).toBe(413);
|
|
147
|
+
expect(data.error).toContain('too large');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects oversized file via application/octet-stream', async () => {
|
|
151
|
+
const { POST } = await import('./upload.js');
|
|
152
|
+
const oversized = createSqliteBuffer(50 * 1024 * 1024 + 1);
|
|
153
|
+
const request = createOctetStreamRequest(oversized);
|
|
154
|
+
|
|
155
|
+
const response = await POST({ request } as any);
|
|
156
|
+
const data = await response.json();
|
|
157
|
+
|
|
158
|
+
expect(response.status).toBe(413);
|
|
159
|
+
expect(data.error).toContain('too large');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('still succeeds if reconnect is not available', async () => {
|
|
163
|
+
const { POST } = await import('./upload.js');
|
|
164
|
+
mockClose.mockImplementation(() => { throw new Error('not available'); });
|
|
165
|
+
|
|
166
|
+
const request = createFormDataRequest(createSqliteBuffer());
|
|
167
|
+
const response = await POST({ request } as any);
|
|
168
|
+
const data = await response.json();
|
|
169
|
+
|
|
170
|
+
expect(response.status).toBe(200);
|
|
171
|
+
expect(data.success).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/api/db/upload.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { APIRoute } from 'astro';
|
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { jsonResponse, jsonError } from '../_utils';
|
|
5
|
+
// @ts-ignore - resolved by Astro build pipeline
|
|
6
|
+
import { db } from 'astro:db';
|
|
5
7
|
|
|
6
8
|
const MAX_DB_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
7
9
|
|
|
@@ -64,6 +66,17 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
64
66
|
|
|
65
67
|
await fs.writeFile(dbPath, dbBuffer);
|
|
66
68
|
|
|
69
|
+
// Reconnect DB client to pick up the new file
|
|
70
|
+
try {
|
|
71
|
+
const client = (db as any).$client;
|
|
72
|
+
if (client?.close && client?.reconnect) {
|
|
73
|
+
await client.close();
|
|
74
|
+
await client.reconnect();
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Reconnect not available — manual restart needed
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
return jsonResponse({
|
|
68
81
|
success: true,
|
|
69
82
|
size: dbBuffer.length,
|
package/update.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# v1.0.18
|
|
2
|
+
|
|
3
|
+
## Fix: DB upload reconnects client after file replacement
|
|
4
|
+
- After writing the new SQLite file, the libsql client is closed and reconnected via `db.$client.close()` / `db.$client.reconnect()`
|
|
5
|
+
- Close and reconnect calls are now properly awaited to prevent race conditions with async DB operations
|
|
6
|
+
- Changes from uploaded database are immediately visible without node restart
|
|
7
|
+
- Graceful fallback: if reconnect is not available, upload still succeeds (manual restart needed)
|
|
8
|
+
|
|
9
|
+
## Tests: DB upload endpoint coverage
|
|
10
|
+
- 10 tests covering upload endpoint — validation, backup, reconnect behavior, size limits
|
|
11
|
+
- Added size limit rejection tests for both `multipart/form-data` and `application/octet-stream` content types
|
|
12
|
+
- Tests verify 50 MB max file size is enforced with proper 413 status response
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
1
16
|
# v1.0.17
|
|
2
17
|
|
|
3
18
|
## Feature: Database download/upload via Editor UI
|