tina4-nodejs 3.12.9 → 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.
@@ -27,8 +27,15 @@ export function tryServeStatic(
27
27
  req: Tina4Request,
28
28
  res: Tina4Response
29
29
  ): boolean {
30
- const url = req.url ?? "/";
31
- const pathname = url.split("?")[0];
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");