tina4-nodejs 3.12.10 → 3.13.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.
- package/CLAUDE.md +17 -17
- package/package.json +13 -4
- package/packages/cli/src/commands/migrate.ts +14 -4
- package/packages/cli/src/commands/migrateRollback.ts +12 -4
- package/packages/cli/src/commands/migrateStatus.ts +10 -4
- package/packages/core/src/__feedback/widget.js +96 -0
- package/packages/core/src/auth.ts +15 -8
- package/packages/core/src/devAdmin.ts +228 -10
- package/packages/core/src/errorOverlay.ts +41 -3
- package/packages/core/src/feedback.ts +277 -0
- package/packages/core/src/index.ts +14 -1
- package/packages/core/src/mcp.test.ts +301 -0
- package/packages/core/src/mcp.ts +302 -7
- package/packages/core/src/plan.ts +56 -15
- package/packages/core/src/request.ts +17 -1
- package/packages/core/src/routeDiscovery.ts +69 -1
- package/packages/core/src/router.ts +75 -16
- package/packages/core/src/server.ts +102 -3
- package/packages/core/src/static.ts +9 -2
- package/packages/core/src/test.ts +246 -0
- package/packages/core/src/types.ts +18 -0
- package/packages/orm/src/database.ts +14 -0
|
@@ -190,6 +190,28 @@ export class Router {
|
|
|
190
190
|
return this.addRoute({ method: "DELETE", pattern: path, handler, middlewares, meta });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Register an explicit HEAD route. By default the framework auto-handles
|
|
195
|
+
* HEAD by falling back to the GET route and stripping the body
|
|
196
|
+
* (RFC 9110 §9.3.2). Use this only when you need a HEAD handler that
|
|
197
|
+
* does something different from GET — e.g. cheaper existence-check
|
|
198
|
+
* logic, custom validator headers without the cost of building the body.
|
|
199
|
+
* The framework still strips the response body for you on the way out.
|
|
200
|
+
*/
|
|
201
|
+
head(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
|
|
202
|
+
return this.addRoute({ method: "HEAD", pattern: path, handler, middlewares, meta });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Register an explicit OPTIONS route. By default the framework auto-
|
|
207
|
+
* handles OPTIONS by building an Allow header from every method
|
|
208
|
+
* registered for the path and returning 204 (RFC 9110 §9.3.7). Use
|
|
209
|
+
* this to take over that behaviour.
|
|
210
|
+
*/
|
|
211
|
+
options(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
|
|
212
|
+
return this.addRoute({ method: "OPTIONS", pattern: path, handler, middlewares, meta });
|
|
213
|
+
}
|
|
214
|
+
|
|
193
215
|
/**
|
|
194
216
|
* Register a route that matches ANY HTTP method.
|
|
195
217
|
*/
|
|
@@ -222,28 +244,65 @@ export class Router {
|
|
|
222
244
|
|
|
223
245
|
// Try exact method first, then ANY routes are already registered under each method
|
|
224
246
|
const routes = this.routes.get(upperMethod);
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
if (routes) {
|
|
248
|
+
const direct = this.matchRoute(routes, path);
|
|
249
|
+
if (direct) return direct;
|
|
250
|
+
|
|
251
|
+
// Trailing-slash redirect — strip a single trailing "/" and retry.
|
|
252
|
+
// Root "/" is intentionally excluded (it's its own route).
|
|
253
|
+
if (
|
|
254
|
+
isTrailingSlashRedirectEnabled() &&
|
|
255
|
+
path.length > 1 &&
|
|
256
|
+
path.endsWith("/")
|
|
257
|
+
) {
|
|
258
|
+
const stripped = path.replace(/\/+$/, "");
|
|
259
|
+
if (stripped.length > 0) {
|
|
260
|
+
const retry = this.matchRoute(routes, stripped);
|
|
261
|
+
if (retry) return retry;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// RFC 9110 §9.3.2: HEAD is identical to GET except for the absence
|
|
267
|
+
// of a response body. If no explicit HEAD route matched, fall back
|
|
268
|
+
// to the GET route — the dispatcher strips the body on the way out.
|
|
269
|
+
if (upperMethod === "HEAD") {
|
|
270
|
+
const getRoutes = this.routes.get("GET");
|
|
271
|
+
if (getRoutes) {
|
|
272
|
+
return this.matchRoute(getRoutes, path);
|
|
241
273
|
}
|
|
242
274
|
}
|
|
243
275
|
|
|
244
276
|
return null;
|
|
245
277
|
}
|
|
246
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Return the list of HTTP methods registered for ``path``, in canonical
|
|
281
|
+
* order GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by the
|
|
282
|
+
* dispatcher to build the ``Allow:`` header on 405 / OPTIONS responses
|
|
283
|
+
* (RFC 9110 §10.2.1, §9.3.7).
|
|
284
|
+
*
|
|
285
|
+
* If GET is registered, HEAD is appended implicitly (HEAD auto-fallback).
|
|
286
|
+
* OPTIONS is appended whenever any method exists for the path (the
|
|
287
|
+
* framework auto-handles OPTIONS).
|
|
288
|
+
*/
|
|
289
|
+
methodsAllowedForPath(path: string): string[] {
|
|
290
|
+
const order = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
291
|
+
const seen = new Set<string>();
|
|
292
|
+
for (const m of order) {
|
|
293
|
+
const routes = this.routes.get(m);
|
|
294
|
+
if (!routes) continue;
|
|
295
|
+
if (this.matchRoute(routes, path)) {
|
|
296
|
+
seen.add(m);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (seen.size > 0) {
|
|
300
|
+
if (seen.has("GET")) seen.add("HEAD");
|
|
301
|
+
seen.add("OPTIONS");
|
|
302
|
+
}
|
|
303
|
+
return order.filter((m) => seen.has(m));
|
|
304
|
+
}
|
|
305
|
+
|
|
247
306
|
/** Inner match against a list of compiled routes, no trailing-slash logic. */
|
|
248
307
|
private matchRoute(routes: CompiledRoute[], path: string): MatchResult | null {
|
|
249
308
|
for (const route of routes) {
|
|
@@ -19,6 +19,7 @@ import { createHealthRoutes } from "./health.js";
|
|
|
19
19
|
import { rateLimiter } from "./rateLimiter.js";
|
|
20
20
|
import { Log } from "./logger.js";
|
|
21
21
|
import { DevAdmin, RequestInspector } from "./devAdmin.js";
|
|
22
|
+
import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
|
|
22
23
|
import { I18n } from "./i18n.js";
|
|
23
24
|
import { stopAllBackgroundTasks } from "./background.js";
|
|
24
25
|
|
|
@@ -913,6 +914,36 @@ ${reset}
|
|
|
913
914
|
const req = createRequest(rawReq);
|
|
914
915
|
const res = createResponse(rawRes);
|
|
915
916
|
|
|
917
|
+
// RFC 9110 §9.3.2: the server MUST NOT send content in a HEAD response.
|
|
918
|
+
// Intercept rawRes.write / rawRes.end so every code path — explicit
|
|
919
|
+
// Router.head() handler, GET auto-fallback, 405 / 404 responses — drops
|
|
920
|
+
// its body. Content-Length is preserved when present, so cache
|
|
921
|
+
// validators / link checkers / monitoring probes still see the size
|
|
922
|
+
// the equivalent GET would have sent.
|
|
923
|
+
if ((rawReq.method ?? "GET").toUpperCase() === "HEAD") {
|
|
924
|
+
const origEnd = rawRes.end.bind(rawRes);
|
|
925
|
+
const origWrite = rawRes.write.bind(rawRes);
|
|
926
|
+
let accumulated = 0;
|
|
927
|
+
rawRes.write = ((chunk?: any, _enc?: any, cb?: any): boolean => {
|
|
928
|
+
if (chunk != null) {
|
|
929
|
+
accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
930
|
+
}
|
|
931
|
+
if (typeof cb === "function") cb();
|
|
932
|
+
return true;
|
|
933
|
+
}) as typeof rawRes.write;
|
|
934
|
+
rawRes.end = ((chunk?: any, _enc?: any, cb?: any): any => {
|
|
935
|
+
if (chunk != null && typeof chunk !== "function") {
|
|
936
|
+
accumulated += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
937
|
+
}
|
|
938
|
+
if (accumulated > 0 && !rawRes.headersSent && !rawRes.hasHeader("Content-Length")) {
|
|
939
|
+
rawRes.setHeader("Content-Length", String(accumulated));
|
|
940
|
+
}
|
|
941
|
+
const realCb = typeof chunk === "function" ? chunk : cb;
|
|
942
|
+
return origEnd(undefined, undefined, realCb);
|
|
943
|
+
void origWrite; // referenced to keep tsc happy
|
|
944
|
+
}) as typeof rawRes.end;
|
|
945
|
+
}
|
|
946
|
+
|
|
916
947
|
// Auto-start session — read cookie, create session, save + set cookie on response end
|
|
917
948
|
{
|
|
918
949
|
const { Session, buildSessionCookie } = await import("./session.js");
|
|
@@ -954,7 +985,7 @@ ${reset}
|
|
|
954
985
|
// Parse request body
|
|
955
986
|
await req.parseBody();
|
|
956
987
|
|
|
957
|
-
const pathname =
|
|
988
|
+
const pathname = req.path;
|
|
958
989
|
|
|
959
990
|
// Track request start time for dev inspector
|
|
960
991
|
const reqStartTime = DevAdmin.isEnabled() ? Date.now() : 0;
|
|
@@ -1001,9 +1032,10 @@ ${reset}
|
|
|
1001
1032
|
};
|
|
1002
1033
|
if (typeof chunk === "string") {
|
|
1003
1034
|
chunk = injectDevToolbar(chunk, toolbarCtx);
|
|
1035
|
+
chunk = injectFeedbackWidget(req, chunk as string);
|
|
1004
1036
|
} else if (Buffer.isBuffer(chunk)) {
|
|
1005
1037
|
const html = chunk.toString("utf-8");
|
|
1006
|
-
chunk = injectDevToolbar(html, toolbarCtx);
|
|
1038
|
+
chunk = injectFeedbackWidget(req, injectDevToolbar(html, toolbarCtx));
|
|
1007
1039
|
}
|
|
1008
1040
|
// Remove content-length since toolbar injection changes body size
|
|
1009
1041
|
if (!res.raw.headersSent) {
|
|
@@ -1019,6 +1051,43 @@ ${reset}
|
|
|
1019
1051
|
return originalEnd(chunk, cb);
|
|
1020
1052
|
};
|
|
1021
1053
|
res.raw.end = wrappedEnd;
|
|
1054
|
+
} else if (
|
|
1055
|
+
feedbackEnabled() &&
|
|
1056
|
+
!pathname.startsWith("/__dev") &&
|
|
1057
|
+
!pathname.startsWith("/__feedback")
|
|
1058
|
+
) {
|
|
1059
|
+
// Production / non-dev path: still inject the feedback widget for
|
|
1060
|
+
// whitelisted users. The injector itself re-checks the whitelist,
|
|
1061
|
+
// path, and html marker so the wrapper is cheap when it no-ops.
|
|
1062
|
+
const originalEnd = res.raw.end.bind(res.raw);
|
|
1063
|
+
const wrappedEnd: typeof res.raw.end = function (
|
|
1064
|
+
chunk?: unknown,
|
|
1065
|
+
encodingOrCb?: BufferEncoding | (() => void),
|
|
1066
|
+
cb?: () => void,
|
|
1067
|
+
) {
|
|
1068
|
+
const contentType = res.raw.getHeader("content-type");
|
|
1069
|
+
if (
|
|
1070
|
+
typeof contentType === "string" &&
|
|
1071
|
+
contentType.includes("text/html")
|
|
1072
|
+
) {
|
|
1073
|
+
if (typeof chunk === "string") {
|
|
1074
|
+
chunk = injectFeedbackWidget(req, chunk);
|
|
1075
|
+
} else if (Buffer.isBuffer(chunk)) {
|
|
1076
|
+
chunk = injectFeedbackWidget(req, chunk.toString("utf-8"));
|
|
1077
|
+
}
|
|
1078
|
+
if (!res.raw.headersSent) {
|
|
1079
|
+
res.raw.removeHeader("content-length");
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (typeof encodingOrCb === "function") {
|
|
1083
|
+
return originalEnd(chunk, encodingOrCb);
|
|
1084
|
+
}
|
|
1085
|
+
if (encodingOrCb !== undefined) {
|
|
1086
|
+
return originalEnd(chunk, encodingOrCb, cb);
|
|
1087
|
+
}
|
|
1088
|
+
return originalEnd(chunk, cb);
|
|
1089
|
+
};
|
|
1090
|
+
res.raw.end = wrappedEnd;
|
|
1022
1091
|
}
|
|
1023
1092
|
|
|
1024
1093
|
// Try static files first (project public dir, src/public dir, then framework built-in)
|
|
@@ -1170,6 +1239,36 @@ ${reset}
|
|
|
1170
1239
|
}
|
|
1171
1240
|
}
|
|
1172
1241
|
|
|
1242
|
+
// RFC 9110 conformance — before falling through to 404, check whether
|
|
1243
|
+
// the PATH is registered under any OTHER method.
|
|
1244
|
+
// - OPTIONS request → 204 with Allow header (§9.3.7)
|
|
1245
|
+
// - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
|
|
1246
|
+
// → 405 with Allow header (§15.5.6 + §10.2.1)
|
|
1247
|
+
const allowedMethods = router.methodsAllowedForPath(pathname);
|
|
1248
|
+
if (allowedMethods.length > 0) {
|
|
1249
|
+
const allowHeader = allowedMethods.join(", ");
|
|
1250
|
+
const requestMethod = (req.method ?? "GET").toUpperCase();
|
|
1251
|
+
if (requestMethod === "OPTIONS") {
|
|
1252
|
+
res.raw.writeHead(204, undefined, { Allow: allowHeader, "Content-Length": "0" });
|
|
1253
|
+
res.raw.end();
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const body = JSON.stringify({
|
|
1257
|
+
error: "Method Not Allowed",
|
|
1258
|
+
path: pathname,
|
|
1259
|
+
method: requestMethod,
|
|
1260
|
+
allow: allowedMethods,
|
|
1261
|
+
statusCode: 405,
|
|
1262
|
+
});
|
|
1263
|
+
res.raw.writeHead(405, httpReason(405), {
|
|
1264
|
+
Allow: allowHeader,
|
|
1265
|
+
"Content-Type": "application/json",
|
|
1266
|
+
"Content-Length": String(Buffer.byteLength(body)),
|
|
1267
|
+
});
|
|
1268
|
+
res.raw.end(body);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1173
1272
|
// 404 — pass canonical reason phrase so the status line is well-formed
|
|
1174
1273
|
const html404 = await renderErrorPage(404, { path: pathname }, templatesDir);
|
|
1175
1274
|
if (html404) {
|
|
@@ -1192,7 +1291,7 @@ ${reset}
|
|
|
1192
1291
|
const html500 = await renderErrorPage(500, {
|
|
1193
1292
|
error_message: errorMessage,
|
|
1194
1293
|
request_id: `${Date.now().toString(36)}`,
|
|
1195
|
-
path:
|
|
1294
|
+
path: req.path,
|
|
1196
1295
|
}, templatesDir);
|
|
1197
1296
|
if (html500) {
|
|
1198
1297
|
res.raw.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -27,8 +27,15 @@ export function tryServeStatic(
|
|
|
27
27
|
req: Tina4Request,
|
|
28
28
|
res: Tina4Response
|
|
29
29
|
): boolean {
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Prefer req.path (always path-only, set by createRequest). Fall back to
|
|
31
|
+
// parsing req.url for hand-rolled request objects in unit tests.
|
|
32
|
+
let pathname = req.path;
|
|
33
|
+
if (!pathname) {
|
|
34
|
+
const raw = req.url ?? "/";
|
|
35
|
+
pathname = raw.startsWith("http")
|
|
36
|
+
? new URL(raw).pathname
|
|
37
|
+
: raw.split("?")[0];
|
|
38
|
+
}
|
|
32
39
|
|
|
33
40
|
// Try exact file match, then index.html for directory requests
|
|
34
41
|
const candidates = [
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 — The Intelligent Native Application 4ramework
|
|
3
|
+
* Copyright 2007 - current Tina4
|
|
4
|
+
* License: MIT https://opensource.org/licenses/MIT
|
|
5
|
+
*
|
|
6
|
+
* Tina4 xUnit-style Test base class.
|
|
7
|
+
*
|
|
8
|
+
* Chapter 18 of the documentation has long shown:
|
|
9
|
+
*
|
|
10
|
+
* class UserApiTest extends Tina4Test {
|
|
11
|
+
* async testHealth() {
|
|
12
|
+
* const resp = await this.get("/health");
|
|
13
|
+
* this.assertEqual(resp.status, 200);
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Until 3.13.0 this class did not exist — examples crashed with
|
|
18
|
+
* "ReferenceError: Tina4Test is not defined". This is the Node.js
|
|
19
|
+
* parity of the Python `tina4_python.test.Test`, PHP `Tina4\Test`,
|
|
20
|
+
* and Ruby `Tina4::Test` classes shipped at the same time.
|
|
21
|
+
*
|
|
22
|
+
* The class has a built-in runner (`Tina4Test.runAll`) so the docs'
|
|
23
|
+
* `npx tina4nodejs test` flow can discover every subclass without
|
|
24
|
+
* an external test framework. HTTP helpers (get/post/put/patch/delete)
|
|
25
|
+
* delegate to a lazy TestClient. Positional assertions match the
|
|
26
|
+
* cross-framework (actual, expected, message) shape.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { TestClient, type TestResponse, type RequestOptions } from "./testClient.js";
|
|
30
|
+
|
|
31
|
+
// Class-level registry so runAll() can discover every subclass.
|
|
32
|
+
const _subclasses: Array<typeof Tina4Test> = [];
|
|
33
|
+
|
|
34
|
+
/** Raised by Tina4Test assertion helpers when an assertion fails. */
|
|
35
|
+
export class AssertionError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "AssertionError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Result of running a Tina4Test subclass (or all subclasses). */
|
|
43
|
+
export interface TestRunResults {
|
|
44
|
+
passed: number;
|
|
45
|
+
failed: number;
|
|
46
|
+
errors: number;
|
|
47
|
+
details: Array<{
|
|
48
|
+
suite: string;
|
|
49
|
+
test: string;
|
|
50
|
+
status: "passed" | "failed" | "error";
|
|
51
|
+
message?: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tina4 xUnit-style test base class — class-based suites with HTTP
|
|
57
|
+
* helpers and positional assertions, zero external deps.
|
|
58
|
+
*
|
|
59
|
+
* Subclass and define `test*` methods:
|
|
60
|
+
*
|
|
61
|
+
* class BasicTest extends Tina4Test {
|
|
62
|
+
* async testAddition() {
|
|
63
|
+
* this.assertEqual(2 + 2, 4, "addition works");
|
|
64
|
+
* }
|
|
65
|
+
* async testHttpHealth() {
|
|
66
|
+
* const resp = await this.get("/health");
|
|
67
|
+
* this.assertEqual(resp.status, 200);
|
|
68
|
+
* }
|
|
69
|
+
* }
|
|
70
|
+
*
|
|
71
|
+
* const results = await Tina4Test.runAll();
|
|
72
|
+
* // → { passed, failed, errors, details }
|
|
73
|
+
*/
|
|
74
|
+
export class Tina4Test {
|
|
75
|
+
private _client: TestClient | null = null;
|
|
76
|
+
|
|
77
|
+
// Auto-register every subclass for the runAll() discovery API.
|
|
78
|
+
// (The static block runs once when the class file loads.)
|
|
79
|
+
static {
|
|
80
|
+
// Skip the base class itself
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** snake_case lifecycle hook — runs before each test. Override in subclasses. */
|
|
84
|
+
async setUp(): Promise<void> {}
|
|
85
|
+
|
|
86
|
+
/** snake_case lifecycle hook — runs after each test. Override in subclasses. */
|
|
87
|
+
async tearDown(): Promise<void> {}
|
|
88
|
+
|
|
89
|
+
// ── HTTP test client (lazy) ─────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** The lazily-created TestClient instance shared by this suite's tests. */
|
|
92
|
+
protected get client(): TestClient {
|
|
93
|
+
if (this._client === null) this._client = new TestClient();
|
|
94
|
+
return this._client;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async get(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
98
|
+
return this.client.get(path, options);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async post(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
102
|
+
return this.client.post(path, options);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async put(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
106
|
+
return this.client.put(path, options);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async patch(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
110
|
+
return this.client.patch(path, options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async delete(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
114
|
+
return this.client.delete(path, options);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Positional assertions — (actual, expected, message) shape ────────
|
|
118
|
+
|
|
119
|
+
assertEqual(actual: unknown, expected: unknown, message?: string): void {
|
|
120
|
+
// Deep equality via JSON for objects, strict for primitives — matches
|
|
121
|
+
// how the Python/PHP/Ruby ports compare.
|
|
122
|
+
const eq =
|
|
123
|
+
actual === expected ||
|
|
124
|
+
(typeof actual === "object" && typeof expected === "object" &&
|
|
125
|
+
JSON.stringify(actual) === JSON.stringify(expected));
|
|
126
|
+
if (!eq) {
|
|
127
|
+
throw new AssertionError(
|
|
128
|
+
message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
assertNotEqual(actual: unknown, expected: unknown, message?: string): void {
|
|
134
|
+
const eq =
|
|
135
|
+
actual === expected ||
|
|
136
|
+
(typeof actual === "object" && typeof expected === "object" &&
|
|
137
|
+
JSON.stringify(actual) === JSON.stringify(expected));
|
|
138
|
+
if (eq) {
|
|
139
|
+
throw new AssertionError(
|
|
140
|
+
message || `Expected ${JSON.stringify(actual)} != ${JSON.stringify(expected)}, but they are equal`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
assertTrue(value: unknown, message?: string): void {
|
|
146
|
+
if (!value) throw new AssertionError(message || `Expected truthy, got ${JSON.stringify(value)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
assertFalse(value: unknown, message?: string): void {
|
|
150
|
+
if (value) throw new AssertionError(message || `Expected falsy, got ${JSON.stringify(value)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
assertNull(value: unknown, message?: string): void {
|
|
154
|
+
if (value !== null) throw new AssertionError(message || `Expected null, got ${JSON.stringify(value)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
assertNotNull(value: unknown, message?: string): void {
|
|
158
|
+
if (value === null) throw new AssertionError(message || "Expected non-null, got null");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async assertRaises(
|
|
162
|
+
expectedClass: new (...args: never[]) => Error,
|
|
163
|
+
fn: () => unknown | Promise<unknown>,
|
|
164
|
+
message?: string
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
await fn();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof expectedClass) return;
|
|
170
|
+
throw new AssertionError(
|
|
171
|
+
message || `Expected ${expectedClass.name}, got ${(err as Error)?.constructor?.name}: ${String(err)}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
throw new AssertionError(message || `Expected ${expectedClass.name} to be raised, but nothing was`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Runner ───────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** Register a subclass for discovery. Called automatically via `extends Tina4Test`. */
|
|
180
|
+
static register(klass: typeof Tina4Test): void {
|
|
181
|
+
if (!_subclasses.includes(klass)) _subclasses.push(klass);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Run every `test*` method on this class. Returns counts and per-test details. */
|
|
185
|
+
static async run(this: typeof Tina4Test): Promise<TestRunResults> {
|
|
186
|
+
const results: TestRunResults = { passed: 0, failed: 0, errors: 0, details: [] };
|
|
187
|
+
const proto = this.prototype as unknown as Record<string, unknown>;
|
|
188
|
+
const methods = Object.getOwnPropertyNames(this.prototype)
|
|
189
|
+
.filter((m) => m.startsWith("test") && typeof proto[m] === "function")
|
|
190
|
+
.sort();
|
|
191
|
+
|
|
192
|
+
for (const method of methods) {
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
194
|
+
const suite = new (this as any)();
|
|
195
|
+
try {
|
|
196
|
+
await suite.setUp();
|
|
197
|
+
await suite[method]();
|
|
198
|
+
await suite.tearDown();
|
|
199
|
+
results.passed++;
|
|
200
|
+
results.details.push({ suite: this.name, test: method, status: "passed" });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof AssertionError) {
|
|
203
|
+
results.failed++;
|
|
204
|
+
results.details.push({ suite: this.name, test: method, status: "failed", message: err.message });
|
|
205
|
+
} else {
|
|
206
|
+
results.errors++;
|
|
207
|
+
results.details.push({
|
|
208
|
+
suite: this.name,
|
|
209
|
+
test: method,
|
|
210
|
+
status: "error",
|
|
211
|
+
message: `${(err as Error)?.constructor?.name || "Error"}: ${String(err)}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run every Tina4Test subclass discovered via auto-registration. */
|
|
220
|
+
static async runAll(options: { quiet?: boolean } = {}): Promise<TestRunResults> {
|
|
221
|
+
const aggregate: TestRunResults = { passed: 0, failed: 0, errors: 0, details: [] };
|
|
222
|
+
for (const klass of _subclasses) {
|
|
223
|
+
const out = await klass.run();
|
|
224
|
+
aggregate.passed += out.passed;
|
|
225
|
+
aggregate.failed += out.failed;
|
|
226
|
+
aggregate.errors += out.errors;
|
|
227
|
+
aggregate.details.push(...out.details);
|
|
228
|
+
}
|
|
229
|
+
if (!options.quiet) {
|
|
230
|
+
// eslint-disable-next-line no-console
|
|
231
|
+
console.log(
|
|
232
|
+
`Tina4 Test results: ${aggregate.passed} passed, ${aggregate.failed} failed, ${aggregate.errors} errors`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return aggregate;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Subclasses array — read-only view used by tests. */
|
|
239
|
+
static get subclasses(): ReadonlyArray<typeof Tina4Test> {
|
|
240
|
+
return _subclasses;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Auto-register subclasses by overriding the `extends` hook. Because JS
|
|
245
|
+
// doesn't have a Ruby/Python-style `inherited` callback, expose `register`
|
|
246
|
+
// as the explicit handshake and document it; tests can call directly.
|
|
@@ -20,6 +20,24 @@ export interface Tina4Session {
|
|
|
20
20
|
export interface Tina4Request extends IncomingMessage {
|
|
21
21
|
params: Record<string, string>;
|
|
22
22
|
query: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Request path only — no query string. Matches `request.path` in
|
|
25
|
+
* Python/PHP/Ruby. Example: `/users/42`.
|
|
26
|
+
*/
|
|
27
|
+
path: string;
|
|
28
|
+
/**
|
|
29
|
+
* Raw query string with no leading "?". Matches `request.query_string`
|
|
30
|
+
* (Python/Ruby) and `request.queryString` (PHP). Example: `"page=2"`.
|
|
31
|
+
*/
|
|
32
|
+
queryString: string;
|
|
33
|
+
/**
|
|
34
|
+
* Full absolute URL — `scheme://host[:port]/path[?query]`.
|
|
35
|
+
* Honours X-Forwarded-Proto / X-Forwarded-Host. Matches PHP/Ruby/Python parity.
|
|
36
|
+
*
|
|
37
|
+
* Note: this overrides Node's native `IncomingMessage.url` (which contains
|
|
38
|
+
* only path+query). Inside Tina4 handlers, `req.url` is always the full URL.
|
|
39
|
+
*/
|
|
40
|
+
url: string;
|
|
23
41
|
body: unknown;
|
|
24
42
|
ip: string;
|
|
25
43
|
files: Record<string, UploadedFile | UploadedFile[]>;
|
|
@@ -869,6 +869,20 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
|
869
869
|
const rawType = config?.type ?? "sqlite";
|
|
870
870
|
const type = rawType === "sqlserver" ? "mssql" : rawType;
|
|
871
871
|
|
|
872
|
+
// Loud warning when we hit the default SQLite path because nothing was
|
|
873
|
+
// configured. Silent fallback was the cause of "my migrations went to the
|
|
874
|
+
// wrong DB" — the developer thought their .env was being honoured.
|
|
875
|
+
// Only warn when the caller passed no config AND no env var was set; an
|
|
876
|
+
// explicit `{ type: "sqlite" }` call is intentional and stays silent.
|
|
877
|
+
if (config === undefined && !process.env.TINA4_DATABASE_URL) {
|
|
878
|
+
const path = "./data/tina4.db";
|
|
879
|
+
console.warn(
|
|
880
|
+
`[tina4] No TINA4_DATABASE_URL set — falling back to SQLite at ${path}. ` +
|
|
881
|
+
`If you meant to use Postgres/MySQL/etc., set TINA4_DATABASE_URL in your .env ` +
|
|
882
|
+
`and re-run. (Was the .env loaded? CLI commands must call loadEnv() first.)`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
872
886
|
switch (type) {
|
|
873
887
|
case "sqlite": {
|
|
874
888
|
const { SQLiteAdapter } = await import("./adapters/sqlite.js");
|