@raiyyan/brisk 0.1.0 → 0.2.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/AGENTS.md +135 -67
- package/README.md +467 -139
- package/dist/src/cli/agents.js +5 -5
- package/dist/src/cli/agents.js.map +1 -1
- package/dist/src/cli/cli.d.ts +1 -1
- package/dist/src/cli/cli.d.ts.map +1 -1
- package/dist/src/cli/cli.js +22 -36
- package/dist/src/cli/cli.js.map +1 -1
- package/dist/src/cli/init.d.ts +8 -11
- package/dist/src/cli/init.d.ts.map +1 -1
- package/dist/src/cli/init.js +120 -96
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/render.d.ts +30 -0
- package/dist/src/cli/render.d.ts.map +1 -0
- package/dist/src/cli/render.js +101 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/system.d.ts +2 -2
- package/dist/src/cli/system.d.ts.map +1 -1
- package/dist/src/cli/system.js +3 -5
- package/dist/src/cli/system.js.map +1 -1
- package/dist/src/client/client.d.ts.map +1 -1
- package/dist/src/client/client.js +32 -5
- package/dist/src/client/client.js.map +1 -1
- package/dist/src/client/index.d.ts +1 -1
- package/dist/src/client/index.d.ts.map +1 -1
- package/dist/src/di/container.d.ts +10 -2
- package/dist/src/di/container.d.ts.map +1 -1
- package/dist/src/di/container.js +20 -3
- package/dist/src/di/container.js.map +1 -1
- package/dist/src/http/app.d.ts +41 -12
- package/dist/src/http/app.d.ts.map +1 -1
- package/dist/src/http/app.js.map +1 -1
- package/dist/src/http/context.d.ts +5 -5
- package/dist/src/http/context.js +5 -5
- package/dist/src/http/controller.d.ts +26 -9
- package/dist/src/http/controller.d.ts.map +1 -1
- package/dist/src/http/controller.js +25 -52
- package/dist/src/http/controller.js.map +1 -1
- package/dist/src/http/error-handler.d.ts +27 -0
- package/dist/src/http/error-handler.d.ts.map +1 -1
- package/dist/src/http/error-handler.js +26 -3
- package/dist/src/http/error-handler.js.map +1 -1
- package/dist/src/http/instance.d.ts +2 -2
- package/dist/src/http/instance.d.ts.map +1 -1
- package/dist/src/http/instance.js +107 -39
- package/dist/src/http/instance.js.map +1 -1
- package/dist/src/http/middleware.d.ts +104 -17
- package/dist/src/http/middleware.d.ts.map +1 -1
- package/dist/src/http/middleware.js +85 -26
- package/dist/src/http/middleware.js.map +1 -1
- package/dist/src/http/openapi.d.ts.map +1 -1
- package/dist/src/http/openapi.js +30 -2
- package/dist/src/http/openapi.js.map +1 -1
- package/dist/src/http/routes.d.ts +29 -7
- package/dist/src/http/routes.d.ts.map +1 -1
- package/dist/src/http/validation.d.ts +16 -0
- package/dist/src/http/validation.d.ts.map +1 -0
- package/dist/src/http/validation.js +51 -0
- package/dist/src/http/validation.js.map +1 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/testing/test-client.d.ts +8 -6
- package/dist/src/testing/test-client.d.ts.map +1 -1
- package/dist/src/testing/test-client.js +3 -2
- package/dist/src/testing/test-client.js.map +1 -1
- package/package.json +3 -2
- package/starters/minimal/.env.example +3 -0
- package/starters/minimal/.oxfmtrc.json +5 -0
- package/starters/minimal/.oxlintrc.json +14 -0
- package/starters/minimal/AGENTS.md +23 -0
- package/starters/minimal/CLAUDE.md +2 -0
- package/starters/minimal/README.md +53 -0
- package/starters/minimal/_gitignore +3 -0
- package/starters/minimal/package.json +29 -0
- package/starters/minimal/src/app.ts +14 -0
- package/starters/minimal/src/env.ts +7 -0
- package/starters/minimal/src/features/index.ts +8 -0
- package/starters/minimal/src/features/notes/notes.controller.ts +38 -0
- package/starters/minimal/src/features/notes/notes.repository.ts +36 -0
- package/starters/minimal/src/features/notes/notes.schema.ts +24 -0
- package/starters/minimal/src/features/notes/notes.service.ts +33 -0
- package/starters/minimal/src/main.ts +4 -0
- package/starters/minimal/src/schemas.ts +26 -0
- package/starters/minimal/templates/feature/src/features/index.ts.controllers.insert +1 -0
- package/starters/minimal/templates/feature/src/features/index.ts.imports.insert +1 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +38 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +36 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
- package/starters/minimal/templates/feature/test/{{kebab}}.service.test.ts +20 -0
- package/starters/minimal/templates/feature/test/{{kebab}}.test.ts +35 -0
- package/starters/minimal/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
- package/starters/minimal/test/notes.service.test.ts +20 -0
- package/starters/minimal/test/notes.test.ts +35 -0
- package/starters/minimal/tsconfig.json +17 -0
- package/starters/minimal/vitest.config.ts +9 -0
- package/starters/sqlite-auth/.dockerignore +8 -0
- package/starters/sqlite-auth/.env.example +9 -0
- package/starters/sqlite-auth/.oxfmtrc.json +5 -0
- package/starters/sqlite-auth/.oxlintrc.json +14 -0
- package/starters/sqlite-auth/AGENTS.md +23 -0
- package/starters/sqlite-auth/CLAUDE.md +2 -0
- package/starters/sqlite-auth/Dockerfile +23 -0
- package/starters/sqlite-auth/README.md +93 -0
- package/starters/sqlite-auth/_gitignore +5 -0
- package/starters/sqlite-auth/compose.yaml +14 -0
- package/starters/sqlite-auth/package.json +35 -0
- package/starters/sqlite-auth/public/.gitkeep +0 -0
- package/starters/sqlite-auth/src/app.ts +39 -0
- package/starters/sqlite-auth/src/auth/tokens.ts +41 -0
- package/starters/sqlite-auth/src/database/clock.ts +4 -0
- package/starters/sqlite-auth/src/database/database.ts +29 -0
- package/starters/sqlite-auth/src/database/errors.ts +11 -0
- package/starters/sqlite-auth/src/database/migrations.ts +29 -0
- package/starters/sqlite-auth/src/env.ts +17 -0
- package/starters/sqlite-auth/src/features/auth/auth.controller.ts +28 -0
- package/starters/sqlite-auth/src/features/index.ts +12 -0
- package/starters/sqlite-auth/src/features/notes/notes.controller.ts +39 -0
- package/starters/sqlite-auth/src/features/notes/notes.repository.ts +47 -0
- package/starters/sqlite-auth/src/features/notes/notes.schema.ts +24 -0
- package/starters/sqlite-auth/src/features/notes/notes.service.ts +33 -0
- package/starters/sqlite-auth/src/features/users/admin-seed.ts +21 -0
- package/starters/sqlite-auth/src/features/users/users.controller.ts +42 -0
- package/starters/sqlite-auth/src/features/users/users.errors.ts +13 -0
- package/starters/sqlite-auth/src/features/users/users.repository.ts +77 -0
- package/starters/sqlite-auth/src/features/users/users.schema.ts +35 -0
- package/starters/sqlite-auth/src/features/users/users.service.ts +80 -0
- package/starters/sqlite-auth/src/main.ts +4 -0
- package/starters/sqlite-auth/src/middlewares/authenticate.ts +29 -0
- package/starters/sqlite-auth/src/schemas.ts +26 -0
- package/starters/sqlite-auth/templates/feature/src/database/migrations.ts.migrations.insert +5 -0
- package/starters/sqlite-auth/templates/feature/src/features/index.ts.controllers.insert +1 -0
- package/starters/sqlite-auth/templates/feature/src/features/index.ts.imports.insert +1 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +39 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +47 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
- package/starters/sqlite-auth/templates/feature/test/{{kebab}}.service.test.ts +20 -0
- package/starters/sqlite-auth/templates/feature/test/{{kebab}}.test.ts +41 -0
- package/starters/sqlite-auth/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
- package/starters/sqlite-auth/test/auth.test.ts +18 -0
- package/starters/sqlite-auth/test/helpers.ts +13 -0
- package/starters/sqlite-auth/test/notes.service.test.ts +20 -0
- package/starters/sqlite-auth/test/notes.test.ts +41 -0
- package/starters/sqlite-auth/test/users.service.test.ts +25 -0
- package/starters/sqlite-auth/test/users.test.ts +164 -0
- package/starters/sqlite-auth/tsconfig.json +17 -0
- package/starters/sqlite-auth/vitest.config.ts +15 -0
- package/dist/src/cli/generate.d.ts +0 -9
- package/dist/src/cli/generate.d.ts.map +0 -1
- package/dist/src/cli/generate.js +0 -39
- package/dist/src/cli/generate.js.map +0 -1
- package/dist/src/cli/templates/auth.d.ts +0 -4
- package/dist/src/cli/templates/auth.d.ts.map +0 -1
- package/dist/src/cli/templates/auth.js +0 -90
- package/dist/src/cli/templates/auth.js.map +0 -1
- package/dist/src/cli/templates/database.d.ts +0 -4
- package/dist/src/cli/templates/database.d.ts.map +0 -1
- package/dist/src/cli/templates/database.js +0 -107
- package/dist/src/cli/templates/database.js.map +0 -1
- package/dist/src/cli/templates/docker.d.ts +0 -4
- package/dist/src/cli/templates/docker.d.ts.map +0 -1
- package/dist/src/cli/templates/docker.js +0 -102
- package/dist/src/cli/templates/docker.js.map +0 -1
- package/dist/src/cli/templates/feature.d.ts +0 -8
- package/dist/src/cli/templates/feature.d.ts.map +0 -1
- package/dist/src/cli/templates/feature.js +0 -145
- package/dist/src/cli/templates/feature.js.map +0 -1
- package/dist/src/cli/templates/project.d.ts +0 -24
- package/dist/src/cli/templates/project.d.ts.map +0 -1
- package/dist/src/cli/templates/project.js +0 -166
- package/dist/src/cli/templates/project.js.map +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { inject, Logger } from "@raiyyan/brisk";
|
|
3
|
+
import { Clock } from "../../database/clock.ts";
|
|
4
|
+
import { EmailTakenError, UserNotFoundError } from "./users.errors.ts";
|
|
5
|
+
import { UsersRepository, type User } from "./users.repository.ts";
|
|
6
|
+
import type { CreateUserBody, ListUsersQuery, UpdateUserBody } from "./users.schema.ts";
|
|
7
|
+
|
|
8
|
+
/** Business rules for users. Throws errors the client understands; knows nothing about Express. */
|
|
9
|
+
export class UsersService {
|
|
10
|
+
readonly #users = inject(UsersRepository);
|
|
11
|
+
readonly #now = inject(Clock);
|
|
12
|
+
readonly #log = inject(Logger);
|
|
13
|
+
|
|
14
|
+
list(query: ListUsersQuery): User[] {
|
|
15
|
+
return this.#users.findMany(query);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(id: string): User {
|
|
19
|
+
const user = this.#users.findById(id);
|
|
20
|
+
if (!user) throw new UserNotFoundError(id);
|
|
21
|
+
return user;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async create({ email, name, password, role }: CreateUserBody): Promise<User> {
|
|
25
|
+
this.#assertEmailFree(email);
|
|
26
|
+
const user = this.#users.insert({
|
|
27
|
+
id: randomUUID(),
|
|
28
|
+
email,
|
|
29
|
+
name,
|
|
30
|
+
role,
|
|
31
|
+
passwordHash: await hashPassword(password),
|
|
32
|
+
createdAt: this.#now(),
|
|
33
|
+
});
|
|
34
|
+
this.#log.info({ userId: user.id, role }, "user created");
|
|
35
|
+
return user;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
update(id: string, { email, name }: UpdateUserBody): User {
|
|
39
|
+
const user = this.get(id);
|
|
40
|
+
if (email !== undefined && email !== user.email) this.#assertEmailFree(email);
|
|
41
|
+
return this.#users.update({ ...user, email: email ?? user.email, name: name ?? user.name });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
remove(id: string): void {
|
|
45
|
+
if (!this.#users.delete(id)) throw new UserNotFoundError(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The user these credentials belong to, or `undefined`; takes the same time whether or not the email exists. */
|
|
49
|
+
async authenticate(email: string, password: string): Promise<User | undefined> {
|
|
50
|
+
const user = this.#users.findByEmail(email.toLowerCase());
|
|
51
|
+
const matches = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH);
|
|
52
|
+
return user && matches ? user : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#assertEmailFree(email: string): void {
|
|
56
|
+
if (this.#users.findByEmail(email)) throw new EmailTakenError(email);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const KEY_LENGTH = 64;
|
|
61
|
+
/** Compared against when the email is unknown, so timing does not reveal which emails exist. */
|
|
62
|
+
const DUMMY_HASH = `${"0".repeat(32)}:${"0".repeat(KEY_LENGTH * 2)}`;
|
|
63
|
+
|
|
64
|
+
function derive(password: string, salt: Buffer): Promise<Buffer> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
scrypt(password, salt, KEY_LENGTH, (error, key) => (error ? reject(error) : resolve(key)));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function hashPassword(password: string): Promise<string> {
|
|
71
|
+
const salt = randomBytes(16);
|
|
72
|
+
return `${salt.toString("hex")}:${(await derive(password, salt)).toString("hex")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
76
|
+
const [salt = "", hash = ""] = stored.split(":");
|
|
77
|
+
const expected = Buffer.from(hash, "hex");
|
|
78
|
+
const actual = await derive(password, Buffer.from(salt, "hex"));
|
|
79
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
80
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineMiddleware,
|
|
3
|
+
ForbiddenError,
|
|
4
|
+
inject,
|
|
5
|
+
UnauthorizedError,
|
|
6
|
+
type SecuritySchemes,
|
|
7
|
+
} from "@raiyyan/brisk";
|
|
8
|
+
import { Tokens, type Role } from "../auth/tokens.ts";
|
|
9
|
+
|
|
10
|
+
/** How clients authenticate, as OpenAPI documents it on every route behind these middlewares. */
|
|
11
|
+
const bearer = { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } satisfies SecuritySchemes;
|
|
12
|
+
|
|
13
|
+
/** Requires `Authorization: Bearer <token>` and adds `user` to ctx; request logs gain the user id. */
|
|
14
|
+
export const authenticate = defineMiddleware({ security: bearer }, ({ req, log }) => {
|
|
15
|
+
const header = req.get("authorization");
|
|
16
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
17
|
+
const claims = token ? inject(Tokens).verify(token) : undefined;
|
|
18
|
+
if (!claims) throw new UnauthorizedError("Valid bearer token required", { challenge: "Bearer" });
|
|
19
|
+
const user = { id: claims.sub, role: claims.role };
|
|
20
|
+
return { user, log: log.child({ userId: user.id }) };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Requires a signed-in user with `role`; use it instead of, or after, `authenticate`. */
|
|
24
|
+
export const requireRole = (role: Role) =>
|
|
25
|
+
defineMiddleware({ security: bearer }, async (ctx) => {
|
|
26
|
+
const { user } = await authenticate.run(ctx);
|
|
27
|
+
if (user.role !== role) throw new ForbiddenError(`Requires the ${role} role`);
|
|
28
|
+
return { user };
|
|
29
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "@raiyyan/brisk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A response field for a timestamp: accepts a `Date` or an ISO string and always sends an ISO 8601 string,
|
|
5
|
+
* so clients type it as `string` and OpenAPI documents it as `date-time`.
|
|
6
|
+
*/
|
|
7
|
+
export const isoDate = () =>
|
|
8
|
+
z
|
|
9
|
+
.union([z.date(), z.string()])
|
|
10
|
+
.transform((value) => (value instanceof Date ? value : new Date(value)).toISOString())
|
|
11
|
+
.pipe(z.iso.datetime());
|
|
12
|
+
|
|
13
|
+
/** Query fields for paged lists: `page` from 1, and `limit` up to 100. */
|
|
14
|
+
export const PageQuery = z.object({
|
|
15
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
16
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** A page of `item`s, with the total across all pages. */
|
|
20
|
+
export const page = <Item extends z.ZodType>(item: Item) =>
|
|
21
|
+
z.object({
|
|
22
|
+
items: z.array(item),
|
|
23
|
+
page: z.number().int(),
|
|
24
|
+
limit: z.number().int(),
|
|
25
|
+
total: z.number().int(),
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{camel}}: {{camel}}Controller,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { {{camel}}Controller } from "./{{kebab}}/{{kebab}}.controller.ts";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineController, inject } from "@raiyyan/brisk";
|
|
2
|
+
import { authenticate } from "../../middlewares/authenticate.ts";
|
|
3
|
+
import { page } from "../../schemas.ts";
|
|
4
|
+
import {
|
|
5
|
+
Create{{entity}}Body,
|
|
6
|
+
{{entity}}IdParams,
|
|
7
|
+
{{entity}}Response,
|
|
8
|
+
List{{pascal}}Query,
|
|
9
|
+
Update{{entity}}Body,
|
|
10
|
+
} from "./{{kebab}}.schema.ts";
|
|
11
|
+
import { {{pascal}}Service } from "./{{kebab}}.service.ts";
|
|
12
|
+
|
|
13
|
+
export const {{camel}}Controller = defineController({ prefix: "/{{kebab}}", use: [authenticate] }, (route) => {
|
|
14
|
+
const {{camel}} = inject({{pascal}}Service);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
list: route.get("/", { query: List{{pascal}}Query, response: page({{entity}}Response) }, ({ query }) =>
|
|
18
|
+
{{camel}}.list(query),
|
|
19
|
+
),
|
|
20
|
+
|
|
21
|
+
get: route.get("/:id", { params: {{entity}}IdParams, response: {{entity}}Response }, ({ params }) =>
|
|
22
|
+
{{camel}}.get(params.id),
|
|
23
|
+
),
|
|
24
|
+
|
|
25
|
+
create: route.post("/", { body: Create{{entity}}Body, response: {{entity}}Response }, ({ body }) =>
|
|
26
|
+
{{camel}}.create(body),
|
|
27
|
+
),
|
|
28
|
+
|
|
29
|
+
update: route.patch(
|
|
30
|
+
"/:id",
|
|
31
|
+
{ params: {{entity}}IdParams, body: Update{{entity}}Body, response: {{entity}}Response },
|
|
32
|
+
({ params, body }) => {{camel}}.update(params.id, body),
|
|
33
|
+
),
|
|
34
|
+
|
|
35
|
+
remove: route.delete("/:id", { params: {{entity}}IdParams }, async ({ params }) => {
|
|
36
|
+
await {{camel}}.remove(params.id);
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { inject, z } from "@raiyyan/brisk";
|
|
2
|
+
import { Database } from "../../database/database.ts";
|
|
3
|
+
|
|
4
|
+
export interface {{entity}} {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A row as SQLite returns it, checked once at the boundary instead of cast. */
|
|
11
|
+
const Row = z
|
|
12
|
+
.object({ id: z.string(), name: z.string(), created_at: z.string() })
|
|
13
|
+
.transform((row): {{entity}} => ({ id: row.id, name: row.name, createdAt: new Date(row.created_at) }));
|
|
14
|
+
|
|
15
|
+
/** Data access only: SQL in, {{entityCamel}} rows out. No HTTP and no business rules. */
|
|
16
|
+
export class {{pascal}}Repository {
|
|
17
|
+
readonly #db = inject(Database);
|
|
18
|
+
|
|
19
|
+
async list({ page, limit }: { page: number; limit: number }): Promise<{ items: {{entity}}[]; total: number }> {
|
|
20
|
+
const rows = this.#db.sql
|
|
21
|
+
.prepare("SELECT * FROM {{snake}} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?")
|
|
22
|
+
.all(limit, (page - 1) * limit);
|
|
23
|
+
const counted = this.#db.sql.prepare("SELECT count(*) AS total FROM {{snake}}").get();
|
|
24
|
+
return { items: rows.map((row) => Row.parse(row)), total: Number(counted?.["total"] ?? 0) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async findById(id: string): Promise<{{entity}} | undefined> {
|
|
28
|
+
const row = this.#db.sql.prepare("SELECT * FROM {{snake}} WHERE id = ?").get(id);
|
|
29
|
+
return row === undefined ? undefined : Row.parse(row);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async insert(row: {{entity}}): Promise<{{entity}}> {
|
|
33
|
+
this.#db.sql
|
|
34
|
+
.prepare("INSERT INTO {{snake}} (id, name, created_at) VALUES (?, ?, ?)")
|
|
35
|
+
.run(row.id, row.name, row.createdAt.toISOString());
|
|
36
|
+
return row;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async update(row: {{entity}}): Promise<{{entity}}> {
|
|
40
|
+
this.#db.sql.prepare("UPDATE {{snake}} SET name = ? WHERE id = ?").run(row.name, row.id);
|
|
41
|
+
return row;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async delete(id: string): Promise<boolean> {
|
|
45
|
+
return Number(this.#db.sql.prepare("DELETE FROM {{snake}} WHERE id = ?").run(id).changes) > 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "@raiyyan/brisk";
|
|
2
|
+
import { isoDate, PageQuery } from "../../schemas.ts";
|
|
3
|
+
|
|
4
|
+
export const Create{{entity}}Body = z.object({
|
|
5
|
+
name: z.string().trim().min(1).max(200),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Keep .default() out of the create schema: .partial() keeps defaults, and a PATCH would reset omitted fields.
|
|
9
|
+
export const Update{{entity}}Body = Create{{entity}}Body.partial();
|
|
10
|
+
|
|
11
|
+
export const {{entity}}IdParams = z.object({ id: z.uuid() });
|
|
12
|
+
|
|
13
|
+
export const List{{pascal}}Query = PageQuery;
|
|
14
|
+
|
|
15
|
+
/** What clients see of a {{entityCamel}}: fields not listed here never leave the server. */
|
|
16
|
+
export const {{entity}}Response = z.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
createdAt: isoDate(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type Create{{entity}}Body = z.infer<typeof Create{{entity}}Body>;
|
|
23
|
+
export type Update{{entity}}Body = z.infer<typeof Update{{entity}}Body>;
|
|
24
|
+
export type List{{pascal}}Query = z.infer<typeof List{{pascal}}Query>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { inject, NotFoundError } from "@raiyyan/brisk";
|
|
3
|
+
import { {{pascal}}Repository, type {{entity}} } from "./{{kebab}}.repository.ts";
|
|
4
|
+
import type { Create{{entity}}Body, List{{pascal}}Query, Update{{entity}}Body } from "./{{kebab}}.schema.ts";
|
|
5
|
+
|
|
6
|
+
/** Business rules for {{camel}}. Throws errors the client understands; knows nothing about Express. */
|
|
7
|
+
export class {{pascal}}Service {
|
|
8
|
+
readonly #repository = inject({{pascal}}Repository);
|
|
9
|
+
|
|
10
|
+
async list(query: List{{pascal}}Query) {
|
|
11
|
+
const { items, total } = await this.#repository.list(query);
|
|
12
|
+
return { items, total, page: query.page, limit: query.limit };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async get(id: string): Promise<{{entity}}> {
|
|
16
|
+
const row = await this.#repository.findById(id);
|
|
17
|
+
if (!row) throw new NotFoundError("{{entity}} not found", { details: { id } });
|
|
18
|
+
return row;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
create(input: Create{{entity}}Body): Promise<{{entity}}> {
|
|
22
|
+
return this.#repository.insert({ id: randomUUID(), name: input.name, createdAt: new Date() });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async update(id: string, { name }: Update{{entity}}Body): Promise<{{entity}}> {
|
|
26
|
+
const row = await this.get(id);
|
|
27
|
+
return this.#repository.update({ ...row, name: name ?? row.name });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async remove(id: string): Promise<void> {
|
|
31
|
+
if (!(await this.#repository.delete(id))) throw new NotFoundError("{{entity}} not found", { details: { id } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Container, NotFoundError } from "@raiyyan/brisk";
|
|
3
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { {{pascal}}Service } from "../src/features/{{kebab}}/{{kebab}}.service.ts";
|
|
5
|
+
|
|
6
|
+
const container = new Container();
|
|
7
|
+
const {{camel}} = container.get({{pascal}}Service);
|
|
8
|
+
await container.init();
|
|
9
|
+
afterAll(() => container.shutdown(() => {}));
|
|
10
|
+
|
|
11
|
+
describe("{{pascal}}Service", () => {
|
|
12
|
+
it("creates a {{entityCamel}} and finds it again", async () => {
|
|
13
|
+
const created = await {{camel}}.create({ name: "unit" });
|
|
14
|
+
expect(await {{camel}}.get(created.id)).toEqual(created);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("throws NotFoundError for an id that does not exist", async () => {
|
|
18
|
+
await expect({{camel}}.get(randomUUID())).rejects.toBeInstanceOf(NotFoundError);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createTestClient } from "@raiyyan/brisk/testing";
|
|
3
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { app } from "../src/app.ts";
|
|
5
|
+
import { signIn } from "./helpers.ts";
|
|
6
|
+
|
|
7
|
+
const api = await createTestClient(app);
|
|
8
|
+
afterAll(() => api.close());
|
|
9
|
+
const headers = await signIn(api);
|
|
10
|
+
|
|
11
|
+
describe("{{kebab}} API", () => {
|
|
12
|
+
it("creates, reads, lists, updates and deletes", async () => {
|
|
13
|
+
const created = await api.call.{{camel}}.create({ headers, body: { name: "first" } });
|
|
14
|
+
const params = { id: created.id };
|
|
15
|
+
expect(created).toMatchObject({ name: "first" });
|
|
16
|
+
|
|
17
|
+
expect(await api.call.{{camel}}.get({ headers, params })).toEqual(created);
|
|
18
|
+
expect((await api.call.{{camel}}.list({ headers, query: { limit: 100 } })).items).toContainEqual(created);
|
|
19
|
+
expect(await api.call.{{camel}}.update({ headers, params, body: { name: "second" } })).toMatchObject({
|
|
20
|
+
id: created.id,
|
|
21
|
+
name: "second",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await api.call.{{camel}}.remove({ headers, params });
|
|
25
|
+
await expect(api.call.{{camel}}.get({ headers, params })).rejects.toMatchObject({ status: 404 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects invalid input with the field that failed", async () => {
|
|
29
|
+
expect((await api.post("/api/{{kebab}}", { headers, body: { name: "" } })).status).toBe(400);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("answers 404 for an id that does not exist", async () => {
|
|
33
|
+
await expect(api.call.{{camel}}.get({ headers, params: { id: randomUUID() } })).rejects.toMatchObject({
|
|
34
|
+
status: 404,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("requires a signed-in user", async () => {
|
|
39
|
+
await expect(api.call.{{camel}}.list()).rejects.toMatchObject({ status: 401 });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BadRequestError, defineMiddleware } from "@raiyyan/brisk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the `x-{{kebab}}` header and adds `{{camel}}` to the ctx of every route that uses it. Replace the check
|
|
5
|
+
* with your own; return nothing for a check-only middleware. To validate path params or a query first,
|
|
6
|
+
* declare them: `defineMiddleware({ params: z.object({ ... }) }, ({ params }) => ...)`.
|
|
7
|
+
*/
|
|
8
|
+
export const {{camel}} = defineMiddleware(({ req }) => {
|
|
9
|
+
const value = req.get("x-{{kebab}}");
|
|
10
|
+
if (!value) throw new BadRequestError("Missing x-{{kebab}} header");
|
|
11
|
+
return { {{camel}}: value };
|
|
12
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Container } from "@raiyyan/brisk";
|
|
3
|
+
import { Tokens } from "../src/auth/tokens.ts";
|
|
4
|
+
|
|
5
|
+
const tokens = new Container().get(Tokens);
|
|
6
|
+
|
|
7
|
+
describe("tokens", () => {
|
|
8
|
+
it("round-trips claims", () => {
|
|
9
|
+
expect(tokens.verify(tokens.sign("u1", "admin"))).toMatchObject({ sub: "u1", role: "admin" });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("rejects forged, malformed and expired tokens", () => {
|
|
13
|
+
const [header, payload] = tokens.sign("u1", "user").split(".");
|
|
14
|
+
expect(tokens.verify(`${header}.${payload}.forged`)).toBeUndefined();
|
|
15
|
+
expect(tokens.verify("not-a-token")).toBeUndefined();
|
|
16
|
+
expect(tokens.verify(tokens.sign("u1", "user", -1))).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TestClient } from "@raiyyan/brisk/testing";
|
|
2
|
+
import type { app } from "../src/app.ts";
|
|
3
|
+
import { env } from "../src/env.ts";
|
|
4
|
+
|
|
5
|
+
/** Signs in as the admin `vitest.config.ts` seeds; returns the headers that authenticate as them. */
|
|
6
|
+
export async function signIn(api: TestClient<typeof app>): Promise<{ authorization: string }> {
|
|
7
|
+
if (!env.ADMIN_EMAIL || !env.ADMIN_PASSWORD)
|
|
8
|
+
throw new Error("vitest.config.ts must set ADMIN_EMAIL and ADMIN_PASSWORD");
|
|
9
|
+
const { token } = await api.call.auth.login({
|
|
10
|
+
body: { email: env.ADMIN_EMAIL, password: env.ADMIN_PASSWORD },
|
|
11
|
+
});
|
|
12
|
+
return { authorization: `Bearer ${token}` };
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Container, NotFoundError } from "@raiyyan/brisk";
|
|
3
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { NotesService } from "../src/features/notes/notes.service.ts";
|
|
5
|
+
|
|
6
|
+
const container = new Container();
|
|
7
|
+
const notes = container.get(NotesService);
|
|
8
|
+
await container.init();
|
|
9
|
+
afterAll(() => container.shutdown(() => {}));
|
|
10
|
+
|
|
11
|
+
describe("NotesService", () => {
|
|
12
|
+
it("creates a note and finds it again", async () => {
|
|
13
|
+
const created = await notes.create({ name: "unit" });
|
|
14
|
+
expect(await notes.get(created.id)).toEqual(created);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("throws NotFoundError for an id that does not exist", async () => {
|
|
18
|
+
await expect(notes.get(randomUUID())).rejects.toBeInstanceOf(NotFoundError);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createTestClient } from "@raiyyan/brisk/testing";
|
|
3
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { app } from "../src/app.ts";
|
|
5
|
+
import { signIn } from "./helpers.ts";
|
|
6
|
+
|
|
7
|
+
const api = await createTestClient(app);
|
|
8
|
+
afterAll(() => api.close());
|
|
9
|
+
const headers = await signIn(api);
|
|
10
|
+
|
|
11
|
+
describe("notes API", () => {
|
|
12
|
+
it("creates, reads, lists, updates and deletes", async () => {
|
|
13
|
+
const created = await api.call.notes.create({ headers, body: { name: "first" } });
|
|
14
|
+
const params = { id: created.id };
|
|
15
|
+
expect(created).toMatchObject({ name: "first" });
|
|
16
|
+
|
|
17
|
+
expect(await api.call.notes.get({ headers, params })).toEqual(created);
|
|
18
|
+
expect((await api.call.notes.list({ headers, query: { limit: 100 } })).items).toContainEqual(created);
|
|
19
|
+
expect(await api.call.notes.update({ headers, params, body: { name: "second" } })).toMatchObject({
|
|
20
|
+
id: created.id,
|
|
21
|
+
name: "second",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await api.call.notes.remove({ headers, params });
|
|
25
|
+
await expect(api.call.notes.get({ headers, params })).rejects.toMatchObject({ status: 404 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects invalid input with the field that failed", async () => {
|
|
29
|
+
expect((await api.post("/api/notes", { headers, body: { name: "" } })).status).toBe(400);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("answers 404 for an id that does not exist", async () => {
|
|
33
|
+
await expect(api.call.notes.get({ headers, params: { id: randomUUID() } })).rejects.toMatchObject({
|
|
34
|
+
status: 404,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("requires a signed-in user", async () => {
|
|
39
|
+
await expect(api.call.notes.list()).rejects.toMatchObject({ status: 401 });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Container, provide } from "@raiyyan/brisk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Clock } from "../src/database/clock.ts";
|
|
4
|
+
import { UsersService } from "../src/features/users/users.service.ts";
|
|
5
|
+
|
|
6
|
+
describe("UsersService", () => {
|
|
7
|
+
it("is testable without HTTP: a container, a frozen clock and an in-memory database", async () => {
|
|
8
|
+
const container = new Container([provide(Clock, () => new Date("2030-05-05T00:00:00Z"))]);
|
|
9
|
+
const users = container.get(UsersService);
|
|
10
|
+
await container.init();
|
|
11
|
+
|
|
12
|
+
const user = await users.create({
|
|
13
|
+
email: "a@b.co",
|
|
14
|
+
name: "A",
|
|
15
|
+
password: "long-enough-password",
|
|
16
|
+
role: "user",
|
|
17
|
+
});
|
|
18
|
+
expect(user.createdAt.toISOString()).toBe("2030-05-05T00:00:00.000Z");
|
|
19
|
+
expect(user.passwordHash).toMatch(/^[0-9a-f]{32}:[0-9a-f]{128}$/);
|
|
20
|
+
expect(await users.authenticate("A@B.CO", "long-enough-password")).toMatchObject({ id: user.id });
|
|
21
|
+
expect(await users.authenticate("a@b.co", "wrong-password")).toBeUndefined();
|
|
22
|
+
|
|
23
|
+
await container.shutdown(() => {});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { provide } from "@raiyyan/brisk";
|
|
2
|
+
import { ApiError } from "@raiyyan/brisk/client";
|
|
3
|
+
import { createTestClient, readError } from "@raiyyan/brisk/testing";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import { app } from "../src/app.ts";
|
|
6
|
+
import { Clock } from "../src/database/clock.ts";
|
|
7
|
+
|
|
8
|
+
const api = await createTestClient(app, {
|
|
9
|
+
providers: [provide(Clock, () => new Date("2026-01-01T00:00:00Z"))],
|
|
10
|
+
});
|
|
11
|
+
const { auth, users } = api.call;
|
|
12
|
+
afterAll(() => api.close());
|
|
13
|
+
|
|
14
|
+
const ada = { email: "Ada@Example.com", name: " Ada ", password: "correct-horse-battery" };
|
|
15
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
16
|
+
|
|
17
|
+
let admin: Record<string, string>;
|
|
18
|
+
let member: Record<string, string>;
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
admin = bearer(
|
|
21
|
+
(await auth.login({ body: { email: "admin@example.com", password: "admin-password-for-tests" } })).token,
|
|
22
|
+
);
|
|
23
|
+
await users.create({
|
|
24
|
+
headers: admin,
|
|
25
|
+
body: { email: "member@example.com", name: "Member", password: "member-password-1" },
|
|
26
|
+
});
|
|
27
|
+
member = bearer(
|
|
28
|
+
(await auth.login({ body: { email: "member@example.com", password: "member-password-1" } })).token,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("auth", () => {
|
|
33
|
+
it("issues a bearer token for valid credentials only", async () => {
|
|
34
|
+
const login = await auth.login({
|
|
35
|
+
body: { email: "ADMIN@example.com", password: "admin-password-for-tests" },
|
|
36
|
+
});
|
|
37
|
+
expect(login).toMatchObject({ tokenType: "Bearer", expiresIn: 3600 });
|
|
38
|
+
await expect(
|
|
39
|
+
auth.login({ body: { email: "admin@example.com", password: "wrong" } }),
|
|
40
|
+
).rejects.toMatchObject({
|
|
41
|
+
status: 401,
|
|
42
|
+
code: "INVALID_CREDENTIALS",
|
|
43
|
+
});
|
|
44
|
+
await expect(auth.login({ body: { email: "nobody@example.com", password: "x" } })).rejects.toMatchObject({
|
|
45
|
+
status: 401,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("requires a valid bearer token", async () => {
|
|
50
|
+
const missing = await api.get("/api/users");
|
|
51
|
+
expect(missing.headers.get("www-authenticate")).toBe("Bearer");
|
|
52
|
+
expect(readError(missing)).toMatchObject({ code: "UNAUTHORIZED" });
|
|
53
|
+
await expect(users.list({ headers: bearer("forged.token.value") })).rejects.toMatchObject({
|
|
54
|
+
status: 401,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("users", () => {
|
|
60
|
+
it("creates a user, normalizing input and hiding the password hash", async () => {
|
|
61
|
+
const user = await users.create({ headers: admin, body: ada });
|
|
62
|
+
expect(user).toEqual({
|
|
63
|
+
id: user.id,
|
|
64
|
+
email: "ada@example.com",
|
|
65
|
+
name: "Ada",
|
|
66
|
+
role: "user",
|
|
67
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns field-level validation errors", async () => {
|
|
72
|
+
const res = await api.post("/api/users", {
|
|
73
|
+
headers: admin,
|
|
74
|
+
body: { email: "nope", name: "", password: "short" },
|
|
75
|
+
});
|
|
76
|
+
expect(res.status).toBe(400);
|
|
77
|
+
expect(readError(res).details).toMatchObject({
|
|
78
|
+
location: "body",
|
|
79
|
+
issues: [{ path: "email" }, { path: "name" }, { path: "password" }],
|
|
80
|
+
});
|
|
81
|
+
expect((await api.get("/api/users/not-a-uuid", { headers: admin })).status).toBe(400);
|
|
82
|
+
await expect(users.list({ headers: admin, query: { limit: 500 } })).rejects.toMatchObject({
|
|
83
|
+
status: 400,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("lists with search and limit", async () => {
|
|
88
|
+
await users.create({ headers: admin, body: { ...ada, email: "grace@example.com", name: "Grace" } });
|
|
89
|
+
expect(await users.list({ headers: member, query: { limit: 1 } })).toHaveLength(1);
|
|
90
|
+
expect(await users.list({ headers: member, query: { search: "GRACE" } })).toEqual([
|
|
91
|
+
expect.objectContaining({ name: "Grace" }),
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("restricts writes to admins", async () => {
|
|
96
|
+
const forbidden = await users.create({ headers: member, body: ada }).catch((error: unknown) => error);
|
|
97
|
+
expect(forbidden).toBeInstanceOf(ApiError);
|
|
98
|
+
expect(forbidden).toMatchObject({ status: 403, code: "FORBIDDEN", message: "Requires the admin role" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("updates, conflicts and deletes", async () => {
|
|
102
|
+
const { id } = await users.create({ headers: admin, body: { ...ada, email: "lin@example.com" } });
|
|
103
|
+
const params = { id };
|
|
104
|
+
|
|
105
|
+
expect(await users.update({ headers: admin, params, body: { name: "Lin" } })).toMatchObject({
|
|
106
|
+
name: "Lin",
|
|
107
|
+
email: "lin@example.com",
|
|
108
|
+
});
|
|
109
|
+
await expect(
|
|
110
|
+
users.update({ headers: admin, params, body: { email: "ada@example.com" } }),
|
|
111
|
+
).rejects.toMatchObject({
|
|
112
|
+
status: 409,
|
|
113
|
+
code: "EMAIL_TAKEN",
|
|
114
|
+
details: { email: "ada@example.com" },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await users.remove({ headers: admin, params });
|
|
118
|
+
await expect(users.get({ headers: admin, params })).rejects.toMatchObject({
|
|
119
|
+
code: "USER_NOT_FOUND",
|
|
120
|
+
details: { id },
|
|
121
|
+
});
|
|
122
|
+
await expect(users.remove({ headers: admin, params })).rejects.toMatchObject({ status: 404 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("lets the database settle a race the service check cannot see", async () => {
|
|
126
|
+
const body = { ...ada, email: "race@example.com" };
|
|
127
|
+
const results = await Promise.allSettled([
|
|
128
|
+
users.create({ headers: admin, body }),
|
|
129
|
+
users.create({ headers: admin, body }),
|
|
130
|
+
]);
|
|
131
|
+
expect(results.map((result) => result.status).toSorted()).toEqual(["fulfilled", "rejected"]);
|
|
132
|
+
const lost = results.find((result) => result.status === "rejected");
|
|
133
|
+
expect(lost?.reason).toMatchObject({ status: 409, code: "ALREADY_EXISTS" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns the authenticated user", async () => {
|
|
137
|
+
expect(await users.me({ headers: member })).toMatchObject({ email: "member@example.com", role: "user" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("OpenAPI", () => {
|
|
142
|
+
it("documents the API, with route names as operation ids", async () => {
|
|
143
|
+
expect((await api.get("/openapi.json")).body).toMatchObject({
|
|
144
|
+
openapi: "3.1.1",
|
|
145
|
+
paths: {
|
|
146
|
+
"/api/auth/login": { post: { operationId: "auth.login" } },
|
|
147
|
+
"/api/users/{id}": {
|
|
148
|
+
get: {
|
|
149
|
+
operationId: "users.get",
|
|
150
|
+
responses: {
|
|
151
|
+
"200": {
|
|
152
|
+
content: {
|
|
153
|
+
"application/json": {
|
|
154
|
+
schema: { properties: { createdAt: { type: "string", format: "date-time" } } },
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|