diff options
-rw-r--r-- | .gitignore | 15 | ||||
-rw-r--r-- | Makefile | 62 | ||||
-rw-r--r-- | deps.mk | 38 | ||||
-rw-r--r-- | index.mjs | 1 | ||||
-rwxr-xr-x | mkdeps.sh | 8 | ||||
-rw-r--r-- | src/api.mjs (renamed from src/api.js) | 12 | ||||
-rwxr-xr-x | src/bin.mjs | 4 | ||||
-rwxr-xr-x | src/cli | 4 | ||||
-rw-r--r-- | src/db.mjs (renamed from src/db.js) | 18 | ||||
-rw-r--r-- | src/escape.mjs | 14 | ||||
-rw-r--r-- | src/hero.mjs | 220 | ||||
-rw-r--r-- | src/ircd.mjs (renamed from src/ircd.js) | 10 | ||||
-rw-r--r-- | src/package.json | 3 | ||||
-rw-r--r-- | src/utils.mjs (renamed from src/utils.js) | 37 | ||||
-rw-r--r-- | src/web.mjs (renamed from src/web.js) | 8 | ||||
-rw-r--r-- | tests/js/db.js | 16 | ||||
-rw-r--r-- | tests/js/db.mjs | 15 | ||||
-rw-r--r-- | tests/js/escape.mjs | 62 | ||||
-rw-r--r-- | tests/js/hero.mjs | 1050 | ||||
-rw-r--r-- | tests/js/ircd.js | 7 | ||||
-rw-r--r-- | tests/js/ircd.mjs | 7 | ||||
-rw-r--r-- | tests/js/utils.js | 130 | ||||
-rw-r--r-- | tests/js/utils.mjs | 225 | ||||
-rw-r--r-- | tests/js/web.js | 7 | ||||
-rw-r--r-- | tests/js/web.mjs | 7 | ||||
-rw-r--r-- | tests/runner.mjs (renamed from tests/runner.js) | 8 |
26 files changed, 1696 insertions, 292 deletions
@@ -1,17 +1,2 @@ -/*.bin -/*.so -/src/config.h -/src/*.o -/src/*.lo -/src/*.to -/src/*.ta -/src/*.t -/src/*.c.txt -/src/*.cat /doc/*.[0-9] /doc/*.3js -/src/index.js -/node_modules/ -/src/napi-sqlite.node -/tests/tests-lib.o -/tests/slurp.o @@ -5,7 +5,7 @@ NAME = papo NAME_UC = $(NAME) URL = papo.im MAILING_LIST = list@$(URL) -TRANSLATIONS = +LANGUAGES = ## Installation prefix. Defaults to "/usr". PREFIX = /usr BINDIR = $(PREFIX)/bin @@ -42,27 +42,18 @@ include deps.mk manpages = $(manpages.in:.in=) sources = \ - $(sources.js) \ - src/cli \ + $(sources.mjs) \ installable = \ $(sources.sql) \ - $(sources.js) \ - src/index.js \ - src/cli \ + $(sources.mjs) \ + src/package.json \ derived-assets = \ - $(NAME).bin \ $(manpages) \ - src/index.js \ - node_modules/dir.sentinel \ - node_modules/ \ - node_modules/$(NAME) \ side-assets = \ - src/logerr.c.txt \ - src/catalog.c.txt \ ircd.sock \ web.sock \ @@ -73,40 +64,23 @@ side-assets = \ all: $(derived-assets) -$(NAME).bin: - ln -fs src/cli $@ - $(manpages): Makefile deps.mk -src/index.js: - ln -fs api.js $@ - -node_modules/dir.sentinel: - mkdir $(@D) - touch $@ - -node_modules/$(NAME): node_modules/dir.sentinel - rm -f $@ - ln -s ../src $@ - touch $@ - -node_modules/: node_modules/dir.sentinel node_modules/$(NAME) +.SUFFIXES: .mjs .mjs-check +tests.mjs-check = $(tests.mjs:.mjs=.mjs-check) +$(tests.mjs-check): + node $*.mjs -.SUFFIXES: .js .js-t -tests.js-t = $(tests.js:.js=.js-t) -$(tests.js-t): - node $*.js - -check-unit: $(tests.js-t) +check-unit: $(tests.mjs-check) integration-tests = \ tests/cli-opts.sh \ -$(integration-tests): $(NAME).bin ALWAYS - sh $@ $(EXEC)$(NAME).bin +$(integration-tests): ALWAYS + sh $@ $(EXEC)src/bin.mjs check-integration: $(integration-tests) @@ -129,7 +103,7 @@ install: all mkdir -p \ '$(DESTDIR)$(BINDIR)' \ '$(DESTDIR)$(JSLIBDIR)' - ln -fs '$(DESTDIR)$(JSLIBDIR)'/cli '$(DESTDIR)$(BINDIR)'/$(NAME) + ln -fs '$(DESTDIR)$(JSLIBDIR)'/bin.mjs '$(DESTDIR)$(BINDIR)'/$(NAME) for f in $(installable); do \ dir='$(DESTDIR)$(JSLIBDIR)'/"`dirname "$${f#src/}"`"; \ mkdir -p "$$dir"; \ @@ -140,11 +114,6 @@ install: all mkdir -p "$$dir"; \ cp -P "$$f" "$$dir"; \ done - for l in en $(TRANSLATIONS); do \ - dir='$(DESTDIR)$(LOCALEDIR)'/"$$l"/LC_MESSAGES; \ - mkdir -p "$$dir"; \ - cp src/$(NAME)."$$l".cat "$$dir"/$(NAME).cat; \ - done sh tools/manpages.sh -ip '$(DESTDIR)$(MANDIR)' $(manpages) ## Uninstalls from $(DESTDIR)$(PREFIX). This is a perfect mirror @@ -155,19 +124,16 @@ uninstall: '$(DESTDIR)$(BINDIR)'/$(NAME) \ '$(DESTDIR)$(JSLIBDIR)' \ '$(DESTDIR)$(SRCDIR)' - for l in en $(TRANSLATIONS); do \ - rm -f '$(DESTDIR)$(LOCALEDIR)'/"$$l"/LC_MESSAGES/$(NAME).cat; \ - done sh tools/manpages.sh -up '$(DESTDIR)$(MANDIR)' $(manpages) run-ircd: all rm -f ircd.sock - ./src/cli ircd ircd.sock + ./src/papo ircd ircd.sock run-web: all rm -f web.sock - ./src/cli web web.sock + ./src/papo web web.sock ## Run the web and IRC server locally. run: all @@ -1,21 +1,29 @@ -sources.js = \ - src/api.js \ - src/db.js \ - src/ircd.js \ - src/utils.js \ - src/web.js \ +sources.mjs = \ + src/api.mjs \ + src/bin.mjs \ + src/db.mjs \ + src/escape.mjs \ + src/hero.mjs \ + src/ircd.mjs \ + src/utils.mjs \ + src/web.mjs \ -tests.js = \ - tests/js/db.js \ - tests/js/ircd.js \ - tests/js/utils.js \ - tests/js/web.js \ +tests.mjs = \ + tests/js/db.mjs \ + tests/js/escape.mjs \ + tests/js/hero.mjs \ + tests/js/ircd.mjs \ + tests/js/utils.mjs \ + tests/js/web.mjs \ sources.sql = \ src/sql/migrations/2023-11-16T15:46:27-03:00-init-auth-data.sql \ +manpages.en.in = \ + doc/papo.en.1.in \ + doc/papo.en.3js.in \ + doc/papo.recipes.en.7.in \ + doc/papo.tutorial.en.7.in \ + doc/papo.why.en.7.in \ -tests/js/db.js-t: tests/js/db.js -tests/js/ircd.js-t: tests/js/ircd.js -tests/js/utils.js-t: tests/js/utils.js -tests/js/web.js-t: tests/js/web.js +manpages.in = $(manpages.en.in) diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..898ccb4 --- /dev/null +++ b/index.mjs @@ -0,0 +1 @@ +export * from "./api.mjs"; @@ -8,9 +8,9 @@ varlist() { } export LANG=POSIX.UTF-8 -find src/*.js -not -name index.js | sort | varlist 'sources.js' -find tests/js/*.js | sort | varlist 'tests.js' +find src/*.mjs | sort | varlist 'sources.mjs' +find tests/js/*.mjs | sort | varlist 'tests.mjs' find src/sql/migrations/*.sql | sort | varlist 'sources.sql' +find doc/*.en.*.in | sort | varlist 'manpages.en.in' -printf '\n' -find tests/js/*.js | sort | sed 's|^\(.*\)$|\1-t: \1|' +echo 'manpages.in = $(manpages.en.in)' @@ -1,8 +1,8 @@ -const { eq } = require("./utils.js"); -const ircd = require("./ircd.js"); -const web = require("./web.js"); +import { eq } from "./utils.mjs"; +import * as ircd from "./ircd.mjs"; +import * as web from "./web.mjs"; -const main = async () => { +export const main = async () => { if (process.argv.length === 3 && process.argv[2] === "-V") { console.log("papo 1970-01-01 0.1.0"); return; @@ -22,7 +22,3 @@ const main = async () => { argv: process.argv, }); }; - -module.exports = { - main, -}; diff --git a/src/bin.mjs b/src/bin.mjs new file mode 100755 index 0000000..5dbbb25 --- /dev/null +++ b/src/bin.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { main } from "./api.mjs"; + +main(); diff --git a/src/cli b/src/cli deleted file mode 100755 index 1de3383..0000000 --- a/src/cli +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -const { main } = require("papo"); - -main(); @@ -1,15 +1,17 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; -const sqlite = require("./napi-sqlite.node"); -const { difference, log } = require("./utils"); +import { difference, log } from "./utils.mjs"; -const MIGRATIONS_DIR = __dirname + "/sql/migrations/"; +const DIRNAME = path.dirname(url.fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = DIRNAME + "/sql/migrations/"; const MIGRATION_FILENAMES = fs.readdirSync(MIGRATIONS_DIR, "UTF-8"); let db = null; -const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { +export const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { assert(dbName); db = await sqlite.open(dbName); @@ -45,7 +47,3 @@ const init = async (dbName = process.env.PAPO_DB_PATH || ":memory:") => { } await db.exec("COMMIT TRANSACTION;"); }; - -module.exports = { - init, -}; diff --git a/src/escape.mjs b/src/escape.mjs new file mode 100644 index 0000000..78e08f8 --- /dev/null +++ b/src/escape.mjs @@ -0,0 +1,14 @@ +const FROM = /[&<>'"]/g; + +const ESCAPES = { + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' +}; + +const mappingFn = c => ESCAPES[c]; + +export const escape = s => + String.prototype.replace.call(s, FROM, mappingFn); diff --git a/src/hero.mjs b/src/hero.mjs new file mode 100644 index 0000000..a8532a8 --- /dev/null +++ b/src/hero.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert"; +import crypto from "node:crypto"; +import http from "node:http"; + +import { assocIn, getIn, first, log } from "./utils.mjs"; + +export const normalizeSegments = segments => + segments.length === 1 && segments[0] === "" ? + segments : + segments.concat([""]); + +export const pathToSegments = path => + normalizeSegments(path + .replace(/^\/*/, "") + .replace(/\/*$/, "") + .replace(/\/+/, "/") + .split("/")); + +export const hasPathParams = segments => + segments.some(s => s.startsWith(":")); + +const HTTP_METHODS = new Set([ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +]); + +const HTTP_METHODS_ARR = [...HTTP_METHODS.keys()].sort(); + +export const addRoute = (table, methods, path, handlerFn) => { + if (methods === "*") { + return addRoute(table, HTTP_METHODS_ARR, path, handlerFn); + } + + if (!Array.isArray(methods)) { + return addRoute(table, [methods], path, handlerFn); + } + + assert.ok(methods.every(m => HTTP_METHODS.has(m))); + + const segments = pathToSegments(path); + const kw = hasPathParams(segments) ? "dynamic" : "static"; + return methods.reduce( + (acc, el) => assocIn(acc, [kw, el].concat(segments), handlerFn), + table, + ); +}; + +export const buildRoutes = routes => + routes.reduce( + (acc, [methods, path, handlerFn]) => + addRoute(acc, methods, path, handlerFn), + {} + ); + +export const findStaticHandler = (table, method, segments) => { + const handlerFn = getIn(table, ["static", method].concat(segments)); + return !handlerFn ? null : { handlerFn, params: {} }; +}; + +/** + * "first" as is: + * - depth-first, as we look for a full match and use it instead of searching + * in parallel; + * - the first param in the same level to show up alphabetically, e.g. + * ":a-user" matches before ":id" does. + */ +export const firstParamMatch = (tree, segments, params) => { + assert.notEqual(segments.length, 0); + + const [seg, ...nextSegments] = segments; + + if (segments.length === 1) { + assert.equal(seg, ""); + const handlerFn = tree[""]; + + return !handlerFn ? null : { handlerFn, params }; + } + + const subtree = tree[seg]; + if (subtree) { + const submatch = firstParamMatch(subtree, nextSegments, params); + // propagation of the end of recursion + if (submatch) { + return submatch; + } + } + + // literal matching failed, we now look for patterns that might match + const paramOptions = Object.keys(tree) + .filter(s => s.startsWith(":")) + .sort(); + return first(paramOptions, param => firstParamMatch(tree[param], nextSegments, { + ...params, + [param.slice(1)]: seg + })); +}; + +export const findDynamicHandler = (table, method, segments) => { + const tree = table?.dynamic?.[method]; + return !tree ? null : firstParamMatch(tree, segments, {}); +}; + +export const findHandler = (table, method, path) => { + const segments = pathToSegments(path); + return ( + findStaticHandler(table, method, segments) || + findDynamicHandler(table, method, segments) + ); +}; + +export const extractQueryParams = s => { + const ret = {}; + for (const [k, v] of new URLSearchParams(s)) { + ret[k] = v; + } + return ret; +}; + +export const handleRequest = async (table, method, url) => { + const [ path, queryParams ] = url.split("?"); + const handler = findHandler(table, method, path); + if (!handler) { + return { + status: 404, + body: "Not Found", + }; + } + + const request = { + params: { + path: handler.params, + query: extractQueryParams(queryParams), + }, + method, + path, + handler: handler.handlerFn, + }; + + return await handler.handlerFn(request); +}; + +export const makeRequestListener = table => async (req, res) => { + const response = await handleRequest(table, req.method, req.url); + res.writeHead(response.status, response.headers); + res.end(response.body); +}; + +export const interceptorsFn = ({ + uuidFn, + logger, +} = { + uuidFn: crypto.randomUUID, + logger: log, +}) => ({ + requestId: (req, next) => next({ ...req, id: uuidFn() }), + logged: async (req, next) => { + const { id, url, method } = req; + logger({ + id, + url, + method, + type: "in-request", + }); + const response = await next(req); + const { status } = response; + logger({ + id, + status, + type: "in-response", + }); + return response; + }, + contentType: async (req, next) => { + const response = await next(req); + const [mimeType, body] = typeof response.body === "string" ? + ["text/html", response.body] : + ["application/json", JSON.stringify(response.body) || ""]; + return { + ...response, + body, + headers: { + "Content-Type": mimeType, + "Content-Length": Buffer.byteLength(body), + ...(response.headers || {}) + }, + }; + }, + serverError: async (req, next) => { + try { + const response = await next(req); + assert.ok("status" in response, `Missing "status"`); + return response; + } catch (error) { + logger({ + id: req.id, + type: "server-error-interceptor", + message: error.message, + }); + return { + status: 500, + body: "Internal Server Error", + }; + } + }, +}); + +export const interceptors = interceptorsFn(); + +export const chainInterceptors = arr => + req => arr.length === 0 ? + req : + arr[0](req, chainInterceptors(arr.slice(1))); + +export const wrapHandler = (fn, arr) => + chainInterceptors(arr.concat([ (req, _next) => fn(req) ])); diff --git a/src/ircd.js b/src/ircd.mjs index 6a02bd6..38903f8 100644 --- a/src/ircd.js +++ b/src/ircd.mjs @@ -1,18 +1,14 @@ -const net = require("node:net"); -const db = require("./db.js"); +import net from "node:net"; +import {} from "./db.mjs"; const server = net.createServer(socket => { socket.write("olar\r\n"); socket.pipe(socket); }); -const app = async udsPath => { +export const app = async udsPath => { await db.init(); server.listen(udsPath, () => { console.log("I'm ircd."); }); }; - -module.exports = { - app, -}; diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..fb3cb61 --- /dev/null +++ b/src/package.json @@ -0,0 +1,3 @@ +{ + "main": "api.mjs" +} diff --git a/src/utils.js b/src/utils.mjs index e1725ef..e0e20a7 100644 --- a/src/utils.js +++ b/src/utils.mjs @@ -1,4 +1,4 @@ -const eq = (a, b) => { +export const eq = (a, b) => { if (a === b) { return true; } @@ -31,14 +31,14 @@ const eq = (a, b) => { return true; }; -const keys = (ks, obj) => +export const keys = (ks, obj) => ks.reduce( (ret, k) => obj.hasOwnProperty(k) ? {...ret, [k]: obj[k]} : ret, {}, ); -const difference = (a, b) => { +export const difference = (a, b) => { const diff = new Set(a); for (const el of b) { diff.delete(el); @@ -46,12 +46,31 @@ const difference = (a, b) => { return diff; }; -const log = o => console.error(JSON.stringify(o)); +export const assocIn = (obj, path, value) => + path.length === 0 ? obj : + path.length === 1 ? { ...obj, [path[0]]: value } : + { + ...obj, + [path[0]]: assocIn( + (obj[path[0]] || {}), + path.slice(1), + value + ) + }; +export const getIn = (obj, path) => + path.length === 0 ? obj : + getIn(obj?.[path[0]], path.slice(1)); -module.exports = { - eq, - keys, - difference, - log, +export const first = (arr, fn) => { + for (const x of arr) { + const ret = fn(x); + if (ret) { + return ret; + } + } + + return null; }; + +export const log = o => console.error(JSON.stringify(o)); @@ -1,4 +1,4 @@ -const http = require("node:http"); +import http from "node:http"; const listProducts = () => {}; const getProduct = () => {}; @@ -23,12 +23,8 @@ const server = http.createServer((req, res) => { res.end("Hello, web!\n"); }); -const app = udsPath => { +export const app = udsPath => { server.listen(udsPath, () => { console.log("I'm web."); }); }; - -module.exports = { - app, -}; diff --git a/tests/js/db.js b/tests/js/db.js deleted file mode 100644 index c99dd1b..0000000 --- a/tests/js/db.js +++ /dev/null @@ -1,16 +0,0 @@ -const { runTests } = require("../runner.js"); -const { init } = require("../../src/db.js"); - - -const test_init = t => { - t.start("init()"); - t.test("FIXME", () => { - // init(); - }); -}; - -const tests = [ - test_init, -]; - -runTests(tests); diff --git a/tests/js/db.mjs b/tests/js/db.mjs new file mode 100644 index 0000000..e4a8a51 --- /dev/null +++ b/tests/js/db.mjs @@ -0,0 +1,15 @@ +import { runTests } from "../runner.mjs"; +import { init } from "../../src/db.mjs"; + + +const test_init = t => { + t.start("init()"); + t.test("FIXME", () => { + // init(); + }); +}; + + +await runTests([ + test_init, +]); diff --git a/tests/js/escape.mjs b/tests/js/escape.mjs new file mode 100644 index 0000000..2cad16a --- /dev/null +++ b/tests/js/escape.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { runTests } from "../runner.mjs"; +import { escape } from "../../src/escape.mjs"; + +const test_escape = t => { + t.start("escape()"); + + t.test("numbers", () => { + assert.equal(escape(0), "0"); + assert.equal(escape(42), "42"); + assert.equal(escape(-1), "-1"); + }); + + t.test("object", () => { + assert.equal(escape({}), "[object Object]"); + assert.equal(escape({ k: "v" }), "[object Object]"); + }); + + t.test("string with special chars", () => { + assert.strictEqual(escape(`"`), """); + assert.strictEqual(escape(`"bar`), ""bar"); + assert.strictEqual(escape(`foo"`), "foo""); + assert.strictEqual(escape(`foo"bar`), "foo"bar"); + assert.strictEqual(escape(`foo""bar`), "foo""bar"); + + assert.strictEqual(escape("&"), "&"); + assert.strictEqual(escape("&bar"), "&bar"); + assert.strictEqual(escape("foo&"), "foo&"); + assert.strictEqual(escape("foo&bar"), "foo&bar"); + assert.strictEqual(escape("foo&&bar"), "foo&&bar"); + + assert.strictEqual(escape("'"), "'"); + assert.strictEqual(escape("'bar"), "'bar"); + assert.strictEqual(escape("foo'"), "foo'"); + assert.strictEqual(escape("foo'bar"), "foo'bar"); + assert.strictEqual(escape("foo''bar"), "foo''bar"); + + assert.strictEqual(escape("<"), "<"); + assert.strictEqual(escape("<bar"), "<bar"); + assert.strictEqual(escape("foo<"), "foo<"); + assert.strictEqual(escape("foo<bar"), "foo<bar"); + assert.strictEqual(escape("foo<<bar"), "foo<<bar"); + + assert.strictEqual(escape(">"), ">"); + assert.strictEqual(escape(">bar"), ">bar"); + assert.strictEqual(escape("foo>"), "foo>"); + assert.strictEqual(escape("foo>bar"), "foo>bar"); + assert.strictEqual(escape("foo>>bar"), "foo>>bar"); + }); + + t.test("the combination of all special characters", () => { + assert.strictEqual( + escape(`foo, "bar", 'baz' & <quux>`), + "foo, "bar", 'baz' & <quux>", + ); + }); +}; + + +await runTests([ + test_escape, +]); diff --git a/tests/js/hero.mjs b/tests/js/hero.mjs new file mode 100644 index 0000000..09e6e54 --- /dev/null +++ b/tests/js/hero.mjs @@ -0,0 +1,1050 @@ +import assert from "node:assert/strict"; +import { runTests } from "../runner.mjs"; +import { assocIn } from "../../src/utils.mjs"; +import { + normalizeSegments, + pathToSegments, + hasPathParams, + addRoute, + buildRoutes, + findStaticHandler, + firstParamMatch, + findDynamicHandler, + findHandler, + extractQueryParams, + handleRequest, + makeRequestListener, + interceptorsFn, + chainInterceptors, + wrapHandler, +} from "../../src/hero.mjs"; + + +const test_normalizeSegments = t => { + t.start("normalizeSegments()"); + + t.test("unchanged when already normalized", () => { + assert.deepEqual(normalizeSegments([""]), [""]); + }); + + t.test("empty terminator added when missing", () => { + assert.deepEqual(normalizeSegments([]), [""]); + assert.deepEqual(normalizeSegments([" "]), [" ", ""]); + assert.deepEqual(normalizeSegments(["_"]), ["_", ""]); + assert.deepEqual(normalizeSegments(["a"]), ["a", ""]); + assert.deepEqual(normalizeSegments([":a"]), [":a", ""]); + assert.deepEqual(normalizeSegments(["path"]), ["path", ""]); + }); +}; + +const test_pathToSegments = t => { + t.start("pathToSegments()"); + + t.test("simple paths", () => { + assert.deepEqual(pathToSegments("/"), [ "" ]); + assert.deepEqual(pathToSegments("/simple"), ["simple", ""]); + assert.deepEqual(pathToSegments("/simple/route"), ["simple", "route", ""]); + + assert.deepEqual(pathToSegments("/api/user/:id"), ["api", "user", ":id", ""]); + assert.deepEqual(pathToSegments("/api/user/:id/info"), ["api", "user", ":id", "info", ""]); + }); + + t.test("extra separators", () => { + assert.deepEqual(pathToSegments("/api/health/"), ["api", "health", ""]); + assert.deepEqual(pathToSegments("/api///health"), ["api", "health", ""]); + assert.deepEqual(pathToSegments("//api///health//"), ["api", "health", ""]); + }); +}; + +const test_hasPathParams = t => { + t.start("hasPathParam()"); + + t.test("has it", () => { + assert(hasPathParams(["some", ":path", ""])); + assert(hasPathParams(["path", ":params", ""])); + assert(hasPathParams(["api", "user", ":id", "info", ""])); + }); + + t.test("doesn't have it", () => { + assert(!hasPathParams([])); + assert(!hasPathParams([ "" ])); + assert(!hasPathParams(["some", "path", ""])); + assert(!hasPathParams(["s:o:m:e:", "p::ath:::"])); + assert(!hasPathParams([" :"])); + }); +}; + +const test_addRoute = t => { + t.start("addRoute()"); + + const fn1 = () => {}; + + t.test("daily usage examples", () => { + assert.deepEqual( + addRoute({}, "GET", "/home", fn1), + { static: { GET: { home: { "": fn1 }}}}, + ); + + assert.deepEqual( + addRoute({}, ["PUT", "POST", "PATCH"], "/api/user", fn1), + { + static: { + PUT: { api: { user: { "": fn1 }}}, + POST: { api: { user: { "": fn1 }}}, + PATCH: { api: { user: { "": fn1 }}}, + }, + }, + ); + + assert.deepEqual( + addRoute({}, "*", "/settings", fn1), + { + static: { + GET: { settings: { "": fn1 }}, + HEAD: { settings: { "": fn1 }}, + POST: { settings: { "": fn1 }}, + PUT: { settings: { "": fn1 }}, + PATCH: { settings: { "": fn1 }}, + DELETE: { settings: { "": fn1 }}, + OPTIONS: { settings: { "": fn1 }}, + }, + }, + ); + + assert.deepEqual( + addRoute({}, "GET", "/", fn1), + { static: { GET: { "": fn1 }}}, + ); + + assert.deepEqual( + addRoute({}, "GET", "/api/user/:id", fn1), + { dynamic: { GET: { api: { user: { ":id": { "": fn1 }}}}}}, + ); + + assert.deepEqual( + addRoute({}, ["PUT", "PATCH"], "/api/org/:orgid/member/:memberid", fn1), + { + dynamic: { + PUT: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, + PATCH: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, + }, + }, + ); + }); + + t.test("bad method", () => { + assert.throws( + () => addRoute({}, "VERB", "/path", fn1), + assert.AssertionError, + ); + }); + + t.test("empty methods array", () => { + assert.deepEqual(addRoute({}, [], "", fn1), {}); + }); + + t.test("subpaths", () => { + const fn1 = () => {}; + const fn2 = () => {}; + + const t1 = addRoute({}, "GET", "/home", fn1); + const t2 = addRoute(t1, "GET", "/home/details", fn2); + assert.deepEqual( + t2, + { + static: { + GET: { + home: { + "": fn1, + details: { + "": fn2, + }, + }, + }, + }, + }, + ); + }); +}; + +const test_buildRoutes = t => { + t.start("buildRoutes()"); + + t.test("empty values", () => { + assert.deepEqual(buildRoutes([]), {}); + }); + + t.test("overwrites", () => { + const fn1 = () => {}; + const fn2 = () => {}; + + const r1 = [ [ "GET", "/", fn1 ] ]; + const r2 = [ [ "GET", "/", fn2 ] ]; + + assert.deepEqual( + buildRoutes(r1), + { + static: { + GET: { + "": fn1, + }, + }, + }, + ); + + assert.deepEqual( + buildRoutes(r2), + { + static: { + GET: { + "": fn2, + }, + }, + }, + ); + + assert.deepEqual( + buildRoutes(r1.concat(r2)), + buildRoutes(r2), + ); + + assert.deepEqual( + buildRoutes(r2.concat(r1)), + buildRoutes(r1), + ); + }); + + t.test("multiple routes built", () => { + const fn1 = () => {}; + const fn2 = () => {}; + const fn3 = () => {}; + const fn4 = () => {}; + const fn5 = () => {}; + + const routes = [ + [ "GET", "/", fn1 ], + [ "GET", "/home", fn2 ], + [ "GET", "/user", fn3 ], + [ "POST", "/user", fn4 ], + [ "GET", "/user/:id", fn5 ], + ]; + + assert.deepEqual( + buildRoutes(routes), + { + static: { + GET: { + "": fn1, + home: { + "": fn2, + }, + user: { + "": fn3, + }, + }, + POST: { + user: { + "": fn4, + }, + }, + }, + dynamic: { + GET: { + user: { + ":id": { + "": fn5, + } + } + } + }, + }, + ); + }); +}; + +const test_findStaticHandler = t => { + t.start("findStaticHandler()"); + + t.test("multiple accesses to the same table", () => { + const fn1 = () => {}; + const fn2 = () => {}; + const fn3 = () => {}; + + const table = { + static: { + GET: { + api: { + home: { "": fn1 }, + settings: { "": fn2 }, + }, + }, + POST: { + api: { + settings: { "": fn3 }, + }, + }, + }, + }; + + assert.deepEqual( + findStaticHandler(table, "GET", [ "api", "home", "" ]), + { handlerFn: fn1, params: {} }, + ); + assert.deepEqual( + findStaticHandler(table, "PUT", [ "api", "home", "" ]), + null, + ); + + assert.deepEqual( + findStaticHandler(table, "GET", [ "api", "settings", "" ]), + { handlerFn: fn2, params: {} }, + ); + assert.deepEqual( + findStaticHandler(table, "POST", [ "api", "settings", "" ]), + { handlerFn: fn3, params: {} }, + ); + assert.deepEqual( + findStaticHandler(table, "PUT", [ "api", "settings", "" ]), + null, + ); + + assert.deepEqual( + findStaticHandler(table, "GET", [ "api", "profile", "" ]), + null, + ); + + assert.deepEqual( + findStaticHandler({}, "GET", [ "api", "profile", "" ]), + null, + ); + }); +}; + +const test_firstParamMatch = t => { + t.start("firstParamMatch()"); + + const params = {}; + const fn1 = () => {}; + const fn2 = () => {}; + + t.test("we BACKTRACK when searching down routes", () => { + const segments = [ "path", "split", "match", "" ]; + + const tree = { + path: { + split: { + MISMATCH: { + "": fn1, + }, + }, + ":param": { + match: { + "": fn2, + }, + }, + }, + }; + + assert.deepEqual( + firstParamMatch(tree, segments, params), + { handlerFn: fn2, params: { param: "split" }}, + ); + }); + + t.test("ambiguous route prefers params at the end", () => { + const segments = [ "path", "param1", "param2", "" ]; + + const tree1 = { + path: { + ":shallower": { + param2: { + "": fn2, + }, + }, + }, + }; + + const tree2 = assocIn(tree1, [ "path", "param1", ":deeper", "" ], fn1); + + assert.deepEqual( + firstParamMatch(tree1, segments, params), + { handlerFn: fn2, params: { shallower: "param1" }}, + ); + + assert.deepEqual( + firstParamMatch(tree2, segments, params), + { handlerFn: fn1, params: { deeper: "param2" }}, + ); + }); + + t.test("when 2 params are possible, we pick the first alphabetically", () => { + const segments = [ "user", "someId", "" ]; + + const tree1 = { + user: { + ":bbb": { + "": fn2, + }, + }, + }; + + const tree2 = assocIn(tree1, ["user", ":aaa", ""], fn1); + + assert.deepEqual( + firstParamMatch(tree1, segments, params), + { handlerFn: fn2, params: { bbb: "someId" }}, + ); + + assert.deepEqual( + firstParamMatch(tree2, segments, params), + { handlerFn: fn1, params: { aaa: "someId" }}, + ); + }); +}; + +const test_findDynamicHandler = t => { + t.start("findDynamicHandler()"); + + t.test("daily usage cases", () => { + const fn1 = () => {}; + const fn2 = () => {}; + + const table = { + dynamic: { + GET: { + users: { + "by-id": { + ":id": { "": fn1 }, + }, + }, + }, + PUT: { + user: { + ":user-id": { + info: { "": fn2 }, + }, + }, + }, + }, + }; + + assert.deepEqual( + findDynamicHandler(table, "GET", [ "users", "by-id", "123", "" ]), + { handlerFn: fn1, params: { "id": "123" }}, + ); + + assert.deepEqual( + findDynamicHandler({}, "GET", [ "users", "by-id", "123", "" ]), + null, + ); + + assert.deepEqual( + findDynamicHandler({ dynamic: { GET: { "": fn1 }}}, "GET", [ "users", "by-id", "123", "" ]), + null, + ); + + assert.deepEqual( + findDynamicHandler(table, "PUT", [ "user", "deadbeef", "info", "" ]), + { handlerFn: fn2, params: { "user-id": "deadbeef" }}, + ); + }); +}; + +const test_findHandler = t => { + t.start("findHandler()"); + + t.test("mix of static and dynamic routes", () => { + const static1 = () => {}; + const static2 = () => {}; + const static3 = () => {}; + const dynamic1 = () => {}; + const dynamic2 = () => {}; + const dynamic3 = () => {}; + const dynamic4 = () => {}; + + const table = { + static: { + GET: { + user: { + "": static1, + }, + pages: { + "": static2, + home: { + "": static3, + }, + }, + }, + }, + dynamic: { + GET: { + user: { + ":id": { + "": dynamic1, + }, + }, + }, + PUT: { + user: { + ":id": { + "": dynamic2, + "info": { + "": dynamic3, + }, + "preferences": { + "": dynamic4, + }, + }, + }, + }, + }, + }; + + assert.deepEqual( + findHandler(table, "GET", "/"), + null, + ); + + assert.deepEqual( + findHandler(table, "GET", "/user/"), + { handlerFn: static1, params: {} }, + ); + + assert.deepEqual( + findHandler(table, "GET", "/pages"), + { handlerFn: static2, params: {} }, + ); + assert.deepEqual( + findHandler(table, "GET", "/pages/home/"), + { handlerFn: static3, params: {} }, + ); + + assert.deepEqual( + findHandler(table, "GET", "/user/some-id"), + { handlerFn: dynamic1, params: { id: "some-id" }}, + ); + assert.deepEqual( + findHandler(table, "GET", "/user/other-id/info"), + null, + ); + + assert.deepEqual( + findHandler(table, "PUT", "/user/other-id/info"), + { handlerFn: dynamic3, params: { id: "other-id" }}, + ); + assert.deepEqual( + findHandler(table, "PUT", "/user/another-id/preferences"), + { handlerFn: dynamic4, params: { id: "another-id" }}, + ); + assert.deepEqual( + findHandler(table, "POST", "/user/another-id/preferences"), + null, + ); + }); +}; + +const test_extractQueryParams = t => { + t.start("extractQueryParams()"); + + t.test("empty values", () => { + assert.deepEqual(extractQueryParams(), {}); + assert.deepEqual(extractQueryParams(null), {}); + assert.deepEqual(extractQueryParams(undefined), {}); + }); + + t.test("we get a flat key-value strings", () => { + assert.deepEqual( + extractQueryParams("a[]=1&b=text&c=___"), + { + "a[]": "1", + b: "text", + c: "___", + }, + ); + }); + + t.test("duplicate values are suppressed, deterministically", () => { + assert.deepEqual(extractQueryParams("a=1&a=2&a=3"), { a: "3" }); + assert.deepEqual(extractQueryParams("a=1&b=2&a=3"), { a: "3", b: "2" }); + }); +}; + +const test_handleRequest = t => { + t.start("handleRequest()"); + + const fn = req => req; + + t.test("request without params", async () => { + const table = { + static: { + GET: { + "": fn, + }, + }, + }; + + assert.deepEqual( + await handleRequest(table, "GET", "/?q=1"), + { + params: { + path: {}, + query: { + q: "1", + }, + }, + method: "GET", + path: "/", + handler: fn, + }, + ); + }); + + t.test("request with params", async () => { + const table = { + dynamic: { + PUT: { + api: { + user: { + ":userId": { + "": fn, + }, + }, + }, + }, + }, + }; + + assert.deepEqual( + await handleRequest(table, "PUT", "/api/user/2222"), + { + params: { + path: { + userId: "2222", + }, + query: {}, + }, + method: "PUT", + path: "/api/user/2222", + handler: fn, + }, + ); + }); + + t.test("missing route", async () => { + assert.deepEqual( + await handleRequest({}, "GET", "/"), + { + status: 404, + body: "Not Found", + }, + ); + }); +}; + +const test_makeRequestListener = t => { + t.start("makeRequestListener()"); + + t.test("straightforward body execution", async () => { + const fn = _ => ({ + status: "test status", + body: "test body", + headers: "test headers", + }); + const routes = [[ "GET", "/route1", fn ]]; + const requestListener = makeRequestListener(buildRoutes(routes)); + const req = { + method: "GET", + url: "/route1", + }; + + const heads = []; + const bodies = []; + const res = { + writeHead: (status, headers) => heads.push({ status, headers }), + end: body => bodies.push(body), + }; + await requestListener(req, res); + + assert.deepEqual( + heads, + [{ + status: "test status", + headers: "test headers", + }], + ); + assert.deepEqual( + bodies, + [ "test body" ], + ); + }); + + t.test("we break if handleRequest() throws an error", async () => { + const fn = _ => { throw new Error("handler error"); }; + const routes = [[ "GET", "/route2", fn ]]; + const requestListener = makeRequestListener(buildRoutes(routes)); + const req = { + method: "GET", + url: "/route2", + }; + + const heads = []; + const bodies = []; + const res = { + writeHead: (status, headers) => heads.push({ status, headers }), + end: body => bodies.push(body), + }; + + await assert.rejects( + async () => await requestListener(req, res), + { message: "handler error" }, + ); + + assert.deepEqual(heads, []); + assert.deepEqual(bodies, []); + }); +}; + +const test_interceptorsFn = t => { + const next = x => x; + + { + t.start("interceptorsFn().requestId()"); + + let i = 0; + const uuidFn = () => `${i++}`; + + t.test("we add an id to whatever we receive", () => { + assert.deepEqual( + interceptorsFn({uuidFn}).requestId({}, next), + { + id: "0", + }, + ); + assert.deepEqual( + interceptorsFn({uuidFn}).requestId({ a: "existing data" }, next), + { + a: "existing data", + id: "1", + }, + ); + }); + + t.test(`we overwrite the "id" if it already exists`, async () => { + assert.deepEqual( + interceptorsFn({uuidFn}).requestId({ id: "before" }, next), + { + id: "2", + }, + ); + }); + } + + { + t.start("interceptorsFn().logged()"); + + t.test("we log before and after next()", async () => { + const contents = []; + const logger = x => contents.push(x); + const status = 201; + const req = { + id: "an ID", + url: "a URL", + method: "a method", + }; + + assert.deepEqual( + await interceptorsFn({logger}).logged(req, _ => ({ status })), + { status }, + ); + assert.deepEqual( + contents, + [ + { ...req, type: "in-request" }, + { id: req.id, status, type: "in-response" }, + ], + ); + }); + } + + { + t.start("interceptorsFn().contentType()"); + + t.test("empty values", async () => { + assert.deepEqual( + await interceptorsFn().contentType({}, next), + { + body: "", + headers: { + "Content-Type": "application/json", + "Content-Length": 0, + }, + }, + ); + + assert.deepEqual( + await interceptorsFn().contentType({ body: "" }, next), + { + body: "", + headers: { + "Content-Type": "text/html", + "Content-Length": 0, + }, + }, + ); + }); + + t.test("body values", async () => { + assert.deepEqual( + await interceptorsFn().contentType({ body: { a: 1 }}, next), + { + body: `{"a":1}`, + headers: { + "Content-Type": "application/json", + "Content-Length": 7, + }, + }, + ); + + assert.deepEqual( + await interceptorsFn().contentType({ body: "<br />" }, next), + { + body: "<br />", + headers: { + "Content-Type": "text/html", + "Content-Length": 6, + }, + }, + ); + }); + + t.test("header values preference", async () => { + assert.deepEqual( + await interceptorsFn().contentType({ + body: "", + headers: { + "Content-Type": "we have preference", + "Content-Length": "and so do we", + }, + }, next), + { + body: "", + headers: { + "Content-Type": "we have preference", + "Content-Length": "and so do we", + }, + } + ); + }); + + t.test("headers get propagated", async () => { + assert.deepEqual( + await interceptorsFn().contentType({ + body: "", + headers: { + "My": "Header", + }, + }, next), + { + body: "", + headers: { + "My": "Header", + "Content-Type": "text/html", + "Content-Length": 0, + }, + }, + ); + }); + } + + { + t.start("interceptorsFn().serverError()"); + + t.test("no-op when no error occurs", async () => { + assert.deepEqual( + await interceptorsFn().serverError({ status: 1 }, next), + { status: 1 }, + ); + }); + + t.test(`an error is thrown if "status" is missing`, async () => { + const contents = []; + const logger = x => contents.push(x); + assert.deepEqual( + await interceptorsFn({ logger }).serverError({ id: 123 }, next), + { + status: 500, + body: "Internal Server Error", + }, + ); + assert.deepEqual( + contents, + [{ + id: 123, + type: "server-error-interceptor", + message: `Missing "status"`, + }], + ); + }); + + t.test("we turn a handler error into a 500 response", async () => { + const contents = []; + const logger = x => contents.push(x); + assert.deepEqual( + await interceptorsFn({ logger }).serverError( + { id: "some ID" }, + _ => { throw new Error("My test error message"); }, + ), + { + status: 500, + body: "Internal Server Error", + }, + ); + assert.deepEqual( + contents, + [{ + id: "some ID", + type: "server-error-interceptor", + message: "My test error message", + }], + ); + }); + } +}; + +const test_chainInterceptors = t => { + t.start("chainInterceptors()"); + + t.test("empty values", () => { + assert.equal(chainInterceptors([])("req"), "req"); + }); + + t.test("the order of interceptors matter", () => { + const a = []; + + const i1 = (req, next) => { + a.push("i1"); + return next(req); + }; + const i2 = (req, next) => { + a.push("i2"); + return next(req); + }; + + assert.equal(chainInterceptors([i1, i2])("req"), "req"); + assert.deepEqual(a, ["i1", "i2"]); + assert.equal(chainInterceptors([i2, i1])("req"), "req"); + assert.deepEqual(a, ["i1", "i2", "i2", "i1"]); + }); + + t.test("with ordering, interceptors implicitly depend on each other", () => { + const i1 = (req, next) => next({ ...req, id: 1 }); + const i2 = (req, next) => next({ ...req, id: req.id + 1 }); + + assert.deepEqual( + chainInterceptors([i1, i2])({}), + { id: 2 }, + ); + + assert.deepEqual( + chainInterceptors([i2])({}), + { id: NaN }, + ); + + assert.deepEqual( + chainInterceptors([i2, i1])({}), + { id: 1 }, + ); + }); + + t.test("we can chain async interceptors", async () => { + const i1 = async (req, next) => await next({ ...req, i1: true }); + const i2 = async (req, next) => await next({ ...req, i2: true }); + + assert.deepEqual( + await chainInterceptors([i1, i2])({}), + { + i1: true, + i2: true, + }, + ); + }); +}; + +const test_wrapHandler = t => { + t.start("wrapHandler()"); + + t.test("a handler with chained interceptors change its behaviour", async () => { + let i = 0; + const uuidFn = () => `${i++}`; + + const contents = []; + const logger = x => contents.push(x); + + const interceptors = interceptorsFn({uuidFn, logger}); + + const fn = async _ => await { status: 1, body: { a: 1 }}; + const wrappedFn = wrapHandler(fn, [ + interceptors.requestId, + interceptors.logged, + interceptors.contentType, + ]); + + const req = { + url: "URL", + method: "METHOD", + }; + + assert.deepEqual( + await fn(req), + { status: 1, body: { a: 1 }}, + ); + assert.deepEqual(contents, []); + + assert.deepEqual( + await wrappedFn(req), + { + status: 1, + body: `{"a":1}`, + headers: { + "Content-Type": "application/json", + "Content-Length": 7, + }, + }, + ); + assert.deepEqual( + contents, + [ + { + id: "0", + url: "URL", + method: "METHOD", + type: "in-request", + }, + { + id: "0", + status: 1, + type: "in-response", + }, + ], + ); + }); +}; + + +await runTests([ + test_normalizeSegments, + test_pathToSegments, + test_hasPathParams, + test_addRoute, + test_buildRoutes, + test_findStaticHandler, + test_firstParamMatch, + test_findDynamicHandler, + test_findHandler, + test_extractQueryParams, + test_handleRequest, + test_makeRequestListener, + test_interceptorsFn, + test_chainInterceptors, + test_wrapHandler, +]); diff --git a/tests/js/ircd.js b/tests/js/ircd.js deleted file mode 100644 index 08bb6dc..0000000 --- a/tests/js/ircd.js +++ /dev/null @@ -1,7 +0,0 @@ -const { runTests } = require("../runner.js"); -const { } = require("../../src/ircd.js"); - -const tests = [ -]; - -runTests(tests); diff --git a/tests/js/ircd.mjs b/tests/js/ircd.mjs new file mode 100644 index 0000000..e5eda66 --- /dev/null +++ b/tests/js/ircd.mjs @@ -0,0 +1,7 @@ +import { runTests } from "../runner.mjs"; +import { } from "../../src/ircd.mjs"; + + + +await runTests([ +]); diff --git a/tests/js/utils.js b/tests/js/utils.js deleted file mode 100644 index 670d89a..0000000 --- a/tests/js/utils.js +++ /dev/null @@ -1,130 +0,0 @@ -const assert = require("node:assert"); - -const { runTests } = require("../runner.js"); -const { eq, keys } = require("../../src/utils.js"); - -const test_eq = t => { - t.start("eq()"); - t.test("scalar values equality", () => { - assert(eq(0, 0)); - assert(eq(100, 100)); - assert(eq(1.5, 1.5)); - assert(eq(-9, -9)); - - assert(!eq(0, 1)); - assert(!eq(100, 99)); - assert(!eq(1.5, 1.4)); - assert(!eq(-9, 9)); - - - assert(eq(null, null)); - assert(eq(undefined, undefined)); - assert(eq("", "")); - assert(eq("a string", "a string")); - - assert(!eq(null, undefined)); - assert(!eq(undefined, 0)); - assert(!eq("", "0")); - assert(!eq("a string", "another string")); - - - assert(eq(true, true)); - assert(eq(false, false)); - - assert(!eq(true, false)); - - - assert(eq(1n, 1n)); - assert(eq(99999999999999n, 99999999999999n)); - }); - - t.test("array equality", () => { - assert(eq([], [])); - - - assert(eq([0, 1, 2], [0, 1, 2])); - assert(eq([0, 1, 2], new Array(0, 1, 2))); - - assert(!eq([0, 1, 2], [0, 1])); - assert(!eq([0, 1], new Array(0, 1, 2))); - - - assert(eq([undefined], [undefined])); - assert(eq([null, 0, "", true], [null, 0, "", true])); - - - assert(eq([[[[0]]]], [[[[0]]]])); - - assert(!eq([[[[0]]]], [0])); - }); - - t.test("object equality", () => { - assert(eq({}, {})); - assert(eq({ a: 1 }, { a: 1 })); - - assert(!eq({ a: 1, b: undefined }, { a: 1 })); - - - assert(eq( - { a: 1, b: { c: { d: "e" } } }, - { a: 1, b: { c: { d: "e" } } }, - )); - - class C {} - // ... FIXME: finish - }); - - t.test("mixed values", () => { - assert(eq( - {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]}, - {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]}, - )); - - assert(!eq(null, {})); - - assert(!eq([], {})); - }); -}; - -const test_keys = t => { - t.start("keys()"); - t.test("happy paths", () => { - assert.deepEqual( - { a: 1, b: 2 }, - keys(["a", "b"], { a: 1, b: 2, c: 3 }), - ); - }); - - t.test("stress scenarios", () => { - assert.deepEqual( - {}, - keys([], {}), - "empty selection of empty object", - ); - - assert.deepEqual( - {}, - keys([], {a: 1}), - "empty selection of non-empty object", - ); - - assert.deepEqual( - {}, - keys(["a"], {}), - "non-empty selection of empty object", - ); - - assert.deepEqual( - { a: undefined, b: null }, - keys(["a", "b", "c"], { a: undefined, b: null }), - "falsy values", - ); - }); -}; - -const tests = [ - test_eq, - test_keys, -]; - -runTests(tests); diff --git a/tests/js/utils.mjs b/tests/js/utils.mjs new file mode 100644 index 0000000..1875ce5 --- /dev/null +++ b/tests/js/utils.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; + +import { runTests } from "../runner.mjs"; +import { + eq, + keys, + assocIn, + getIn, + first, + log, +} from "../../src/utils.mjs"; + +const test_eq = t => { + t.start("eq()"); + t.test("scalar values equality", () => { + assert(eq(0, 0)); + assert(eq(100, 100)); + assert(eq(1.5, 1.5)); + assert(eq(-9, -9)); + + assert(!eq(0, 1)); + assert(!eq(100, 99)); + assert(!eq(1.5, 1.4)); + assert(!eq(-9, 9)); + + + assert(eq(null, null)); + assert(eq(undefined, undefined)); + assert(eq("", "")); + assert(eq("a string", "a string")); + + assert(!eq(null, undefined)); + assert(!eq(undefined, 0)); + assert(!eq("", "0")); + assert(!eq("a string", "another string")); + + + assert(eq(true, true)); + assert(eq(false, false)); + + assert(!eq(true, false)); + + + assert(eq(1n, 1n)); + assert(eq(99999999999999n, 99999999999999n)); + }); + + t.test("array equality", () => { + assert(eq([], [])); + + + assert(eq([0, 1, 2], [0, 1, 2])); + assert(eq([0, 1, 2], new Array(0, 1, 2))); + + assert(!eq([0, 1, 2], [0, 1])); + assert(!eq([0, 1], new Array(0, 1, 2))); + + + assert(eq([undefined], [undefined])); + assert(eq([null, 0, "", true], [null, 0, "", true])); + + + assert(eq([[[[0]]]], [[[[0]]]])); + + assert(!eq([[[[0]]]], [0])); + }); + + t.test("object equality", () => { + assert(eq({}, {})); + assert(eq({ a: 1 }, { a: 1 })); + + assert(!eq({ a: 1, b: undefined }, { a: 1 })); + + + assert(eq( + { a: 1, b: { c: { d: "e" } } }, + { a: 1, b: { c: { d: "e" } } }, + )); + }); + + t.test("mixed values", () => { + assert(eq( + {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]}, + {a: ["", 1, 2, 3, [{ b: { c: [ "d", "e" ]}}]]}, + )); + + assert(!eq(null, {})); + + assert(!eq([], {})); + }); +}; + +const test_keys = t => { + t.start("keys()"); + t.test("happy paths", () => { + assert.deepEqual( + { a: 1, b: 2 }, + keys(["a", "b"], { a: 1, b: 2, c: 3 }), + ); + }); + + t.test("stress scenarios", () => { + assert.deepEqual( + {}, + keys([], {}), + "empty selection of empty object", + ); + + assert.deepEqual( + {}, + keys([], {a: 1}), + "empty selection of non-empty object", + ); + + assert.deepEqual( + {}, + keys(["a"], {}), + "non-empty selection of empty object", + ); + + assert.deepEqual( + { a: undefined, b: null }, + keys(["a", "b", "c"], { a: undefined, b: null }), + "falsy values", + ); + }); +}; + +const test_assocIn = t => { + t.start("assocIn()"); + + t.test("empty values", () => { + assert.deepEqual(assocIn({}, [], null), {}); + assert.deepEqual(assocIn({ k: "v" }, [], null), { k: "v" }); + }); + + t.test("adding values", () => { + assert.deepEqual(assocIn({}, ["k"], "v"), { k: "v" }); + assert.deepEqual(assocIn({}, ["k1", "k2"], "v"), { k1: { k2: "v" }}); + assert.deepEqual(assocIn({}, ["k1", "k2", "k3"], "v"), { k1: { k2: { k3: "v" }}}); + assert.deepEqual(assocIn({ k: "v" }, ["k1", "k2"], "v"), { k: "v", k1: { k2: "v" }}); + }); + + t.test("replacing values", () => { + assert.deepEqual( + assocIn( + { k1: { k2: { k3: "before" }}}, + ["k1", "k2", "k3"], + "after" + ), + { k1: { k2: { k3: "after" }}} + ); + }); +}; + +const test_getIn = t => { + t.start("getIn()"); + + t.test("empty values", () => { + assert.deepEqual(getIn({}, []), {}); + assert.deepEqual(getIn({ k: "v" }, []), { k: "v" }); + }); + + t.test("missing values", () => { + assert.deepEqual(getIn({}, ["a", "b", "c"]), undefined); + assert.deepEqual(getIn({ a: {}}, ["a", "b", "c"]), undefined); + assert.deepEqual(getIn({ a: { b: {}}}, ["a", "b", "c"]), undefined); + assert.deepEqual(getIn({ a: { b: {}, c: {}}}, ["a", "b", "c"]), undefined); + }); + + t.test("nested valeues", () => { + assert.deepEqual(getIn({ a: { b: { c: { d: "e" }}}}, ["a", "b", "c", "d"]), "e"); + }); +}; + +const test_first = t => { + t.start("first()"); + + t.test("empty values", () => { + assert.equal(first([], () => {}), null); + }); + + t.test("when function doesn't transform, it behaves similarly to [].find()", () => { + const arr1 = [ 0, null, undefined, "", 1, 2 ]; + assert.equal(first(arr1, x => x), 1); + assert.equal(arr1.find(x => x), 1); + + const arr2 = [ 0, null, undefined, "", false ]; + assert.equal(first(arr2, x => x), null); + assert.equal(arr2.find(x => x), undefined); + }); + + t.test("when it does transform, we return the transformed value", () => { + const arr = [ 1, 3, 5, 6 ]; + + assert.equal( + first(arr, x => x % 2 === 0 && "a brand new value"), + "a brand new value", + ); + }); +}; + +const test_log = t => { + t.start("log()"); + + t.test("we can log data", () => { + log({ a: 1, type: "log-test" }); + }); + + t.test("we can't log unserializable things", () => { + const obj = { self: null }; + obj.self = obj; + assert.throws(() => log(obj), TypeError); + }); +}; + + +await runTests([ + test_eq, + test_keys, + test_assocIn, + test_getIn, + test_first, + test_log, +]); diff --git a/tests/js/web.js b/tests/js/web.js deleted file mode 100644 index 6562433..0000000 --- a/tests/js/web.js +++ /dev/null @@ -1,7 +0,0 @@ -const { runTests } = require("../runner.js"); -const { } = require("../../src/web.js"); - -const tests = [ -]; - -runTests(tests); diff --git a/tests/js/web.mjs b/tests/js/web.mjs new file mode 100644 index 0000000..00cce70 --- /dev/null +++ b/tests/js/web.mjs @@ -0,0 +1,7 @@ +import { runTests } from "../runner.mjs"; +import { } from "../../src/web.mjs"; + + + +await runTests([ +]); diff --git a/tests/runner.js b/tests/runner.mjs index 4772a28..79bda8c 100644 --- a/tests/runner.js +++ b/tests/runner.mjs @@ -1,4 +1,4 @@ -const { eq } = require("../src/utils.js"); +import { eq } from "../src/utils.mjs"; class AssertionError extends Error {} @@ -25,12 +25,8 @@ const t = { }, }; -const runTests = async tests => { +export const runTests = async tests => { for (const testFn of tests) { await testFn(t); } }; - -module.exports = { - runTests, -}; |