diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 16d5a16..c52e624 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,14 +24,6 @@ jobs: - run: bun install - run: bun run prettier --check . - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 - - run: bun install - - run: bun run typedoc api.ts - test: runs-on: ubuntu-latest @@ -60,6 +52,6 @@ jobs: complete: runs-on: ubuntu-latest - needs: [build, lint, docs, test] + needs: [build, lint, test] steps: - run: echo "Done!" diff --git a/__tests__/actions/session.test.ts b/__tests__/actions/session.test.ts new file mode 100644 index 0000000..de8e138 --- /dev/null +++ b/__tests__/actions/session.test.ts @@ -0,0 +1,66 @@ +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { api, type ActionResponse } from "../../api"; +import { config } from "../../config"; +import { users } from "../../schema/users"; +import { hashPassword } from "../../ops/UserOps"; +import type { SessionCreate } from "../../actions/session"; + +const url = `http://${config.server.web.host}:${config.server.web.port}`; + +beforeAll(async () => { + await api.start(); + await api.db.clearDatabase(); + await api.db.db.insert(users).values({ + name: "Mario Mario", + email: "mario@example.com", + password_hash: await hashPassword("mushroom1"), + }); +}); + +afterAll(async () => { + await api.stop(); +}); + +test("returns user when matched", async () => { + const res = await fetch(url + "/api/session", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "mario@example.com", + password: "mushroom1", + }), + }); + const response = (await res.json()) as ActionResponse; + expect(res.status).toBe(200); + + expect(response.id).toEqual(1); + expect(response.name).toEqual("Mario Mario"); +}); + +test("fails when users is not found", async () => { + const res = await fetch(url + "/api/session", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "bowser@example.com", + password: "xxx", + }), + }); + const response = (await res.json()) as ActionResponse; + expect(res.status).toBe(500); + expect(response.error?.message).toEqual("User not found"); +}); + +test("fails when passwords do not match", async () => { + const res = await fetch(url + "/api/session", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "mario@example.com", + password: "yoshi", + }), + }); + const response = (await res.json()) as ActionResponse; + expect(res.status).toBe(500); + expect(response.error?.message).toEqual("Password does not match"); +}); diff --git a/actions/hello.ts b/actions/hello.ts index e145764..cc8928f 100644 --- a/actions/hello.ts +++ b/actions/hello.ts @@ -1,6 +1,7 @@ import { Action, type ActionParams } from "../api"; import { HTTP_METHOD } from "../classes/Action"; import { ensureString } from "../util/formatters"; +import { nameValidator } from "../util/validators"; export class Hello implements Action { name = "hello"; @@ -8,8 +9,7 @@ export class Hello implements Action { inputs = { name: { required: true, - validator: (p: string) => - p.length <= 0 ? "Name must be at least 1 character" : true, + validator: nameValidator, formatter: ensureString, }, }; diff --git a/actions/session.ts b/actions/session.ts new file mode 100644 index 0000000..64e693e --- /dev/null +++ b/actions/session.ts @@ -0,0 +1,46 @@ +import { eq } from "drizzle-orm"; +import { api, type Action, type ActionParams } from "../api"; +import { users } from "../schema/users"; +import { ensureString } from "../util/formatters"; +import { emailValidator, passwordValidator } from "../util/validators"; +import { serializeUser, checkPassword } from "../ops/UserOps"; +import { ErrorType, TypedError } from "../classes/TypedError"; +import { HTTP_METHOD } from "../classes/Action"; + +export class SessionCreate implements Action { + name = "sessionCreate"; + web = { route: "/session", method: HTTP_METHOD.PUT }; + inputs = { + email: { + required: true, + validator: emailValidator, + formatter: ensureString, + }, + password: { + required: true, + validator: passwordValidator, + formatter: ensureString, + }, + }; + + run = async (params: ActionParams) => { + const [user] = await api.db.db + .select() + .from(users) + .where(eq(users.email, params.email.toLowerCase())); + + if (!user) { + throw new TypedError("User not found", ErrorType.CONNECTION_ACTION_RUN); + } + + const passwordMatch = await checkPassword(user, params.password); + if (!passwordMatch) { + throw new TypedError( + "Password does not match", + ErrorType.CONNECTION_ACTION_RUN, + ); + } + + return serializeUser(user); + }; +} diff --git a/actions/user.ts b/actions/user.ts index c182830..fa7c1f4 100644 --- a/actions/user.ts +++ b/actions/user.ts @@ -3,6 +3,11 @@ import { HTTP_METHOD } from "../classes/Action"; import { hashPassword, serializeUser } from "../ops/UserOps"; import { users } from "../schema/users"; import { ensureString } from "../util/formatters"; +import { + emailValidator, + nameValidator, + passwordValidator, +} from "../util/validators"; export class UserCreate implements Action { name = "userCreate"; @@ -10,35 +15,30 @@ export class UserCreate implements Action { inputs = { name: { required: true, - validator: (p: string) => - p.length < 3 ? "Name must be at least 3 characters" : true, + validator: nameValidator, formatter: ensureString, }, email: { required: true, - validator: (p: string) => - p.length < 3 || !p.includes("@") ? "Email invalid" : true, + validator: emailValidator, formatter: ensureString, }, password: { required: true, - validator: (p: string) => - p.length < 3 ? "Password must be at least 3 characters" : true, + validator: passwordValidator, formatter: ensureString, }, }; async run(params: ActionParams) { - const user = ( - await api.db.db - .insert(users) - .values({ - name: params.name, - email: params.email, - password_hash: await hashPassword(params.password), - }) - .returning() - )[0]; + const [user] = await api.db.db + .insert(users) + .values({ + name: params.name, + email: params.email.toLowerCase(), + password_hash: await hashPassword(params.password), + }) + .returning(); return serializeUser(user); } diff --git a/classes/Action.ts b/classes/Action.ts index 77fa91c..0236c39 100644 --- a/classes/Action.ts +++ b/classes/Action.ts @@ -1,6 +1,7 @@ import type { Inputs } from "./Inputs"; import type { Connection } from "./Connection"; import type { Input } from "./Input"; +import type { TypedError } from "./TypedError"; export enum HTTP_METHOD { "GET" = "GET", @@ -25,7 +26,7 @@ export abstract class Action { name: string; description?: string; inputs: Inputs; - web: { + web?: { route: RegExp | string; method: HTTP_METHOD; }; @@ -62,6 +63,4 @@ type TypeFromFormatterOrUnknown = I["formatter"] extends ( : unknown; export type ActionResponse = Awaited> & - Partial<{ - error?: { message: string; stack?: string }; - }>; + Partial<{ error?: TypedError }>; diff --git a/classes/TypedError.ts b/classes/TypedError.ts index 64de56a..bb2f0ac 100644 --- a/classes/TypedError.ts +++ b/classes/TypedError.ts @@ -1,24 +1,24 @@ export enum ErrorType { // general - "SERVER_INITIALIZATION", - "SERVER_START", - "SERVER_STOP", + "SERVER_INITIALIZATION" = "SERVER_INITIALIZATION", + "SERVER_START" = "SERVER_START", + "SERVER_STOP" = "SERVER_STOP", // init - "CONFIG_ERROR", - "INITIALIZER_VALIDATION", - "ACTION_VALIDATION", - "TASK_VALIDATION", - "SERVER_VALIDATION", + "CONFIG_ERROR" = "CONFIG_ERROR", + "INITIALIZER_VALIDATION" = "INITIALIZER_VALIDATION", + "ACTION_VALIDATION" = "ACTION_VALIDATION", + "TASK_VALIDATION" = "TASK_VALIDATION", + "SERVER_VALIDATION" = "SERVER_VALIDATION", // actions - "CONNECTION_SERVER_ERROR", - "CONNECTION_ACTION_NOT_FOUND", - "CONNECTION_ACTION_PARAM_REQUIRED", - "CONNECTION_ACTION_PARAM_DEFAULT", - "CONNECTION_ACTION_PARAM_VALIDATION", - "CONNECTION_ACTION_PARAM_FORMATTING", - "CONNECTION_ACTION_RUN", + "CONNECTION_SERVER_ERROR" = "CONNECTION_SERVER_ERROR", + "CONNECTION_ACTION_NOT_FOUND" = "CONNECTION_ACTION_NOT_FOUND", + "CONNECTION_ACTION_PARAM_REQUIRED" = "CONNECTION_ACTION_PARAM_REQUIRED", + "CONNECTION_ACTION_PARAM_DEFAULT" = "CONNECTION_ACTION_PARAM_DEFAULT", + "CONNECTION_ACTION_PARAM_VALIDATION" = "CONNECTION_ACTION_PARAM_VALIDATION", + "CONNECTION_ACTION_PARAM_FORMATTING" = "CONNECTION_ACTION_PARAM_FORMATTING", + "CONNECTION_ACTION_RUN" = "CONNECTION_ACTION_RUN", } export class TypedError extends Error { diff --git a/package.json b/package.json index 62fa4b4..fa109da 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "@types/pg": "^8.11.4", "@types/react-dom": "^18.2.22", "drizzle-kit": "^0.20.14", - "prettier": "^3.2.5", - "typedoc": "^0.25.12" + "prettier": "^3.2.5" }, "peerDependencies": { "typescript": "^5.0.0" @@ -27,7 +26,6 @@ "migrations": "bun run migrations.ts", "lint": "prettier --check .", "pretty": "prettier --write .", - "ci": "bun run type_doc && bun run lint && bun test", - "type_doc": "bun run typedoc api.ts" + "ci": "bun run lint && bun test" } } diff --git a/servers/web.ts b/servers/web.ts index 39e1a3d..aabcd69 100644 --- a/servers/web.ts +++ b/servers/web.ts @@ -24,7 +24,7 @@ export class WebServer extends Server> { async start() { logger.info( - `starting web server @ ${config.server.web.host}:${config.server.web.port}`, + `starting web server @ http://${config.server.web.host}:${config.server.web.port}`, ); this.server = Bun.serve({ @@ -193,7 +193,7 @@ export class WebServer extends Server> { ); for (const action of api.actions.actions) { - if (!action.web.route) continue; + if (!action?.web?.route) continue; const matcher = action.web.route instanceof RegExp diff --git a/util/validators.ts b/util/validators.ts new file mode 100644 index 0000000..9fc85f1 --- /dev/null +++ b/util/validators.ts @@ -0,0 +1,27 @@ +const stringLengthValidator = (s: string, minLength: number) => { + if (s.length < minLength) { + throw new Error( + `This field is required and must be at least ${minLength} characters long`, + ); + } +}; + +export const nameValidator = (p: string) => { + stringLengthValidator(p, 3); + + return true as const; +}; + +export const emailValidator = (p: string): true => { + stringLengthValidator(p, 3); + if (!p.includes("@")) throw new Error(`This is not a valid email`); + if (!p.includes(".")) throw new Error(`This is not a valid email`); + + return true as const; +}; + +export const passwordValidator = (p: string): true => { + stringLengthValidator(p, 3); + + return true as const; +};