zenstack/tests/e2e/orm/client-api/procedures.test.ts
Mike Willbanks 2172614e0e
custom procedures (#551)
* feat: custom procs

* chore: cleanup

* fix: remove $procedures from client

* fix: failing test due to previous alias

* feat(custom-procs)!: make procedures envelope-only via $procs

- Switch procedure calls to `db.$procs.name({ args: {...} })` (no positional args)
- Remove legacy `$procedures` alias entirely (client API + server routing/logging)
- Validate procedure envelope input (`args` object, required/unknown keys)
- Keep TanStack Query procedure hooks as `(args, options)` (with conditional args optionality)
- Update server/ORM/client tests for the envelope API

* fix: code review feedback

* fix: code review comments

* fix: coderabbit review comments

* fix: remove useless proxy method

* test: add a couple of e2e tests that verify both typing and runtime

* test: improve e2e tests

* test: add missing mutation flag

* regenerate test schema

* refactor: procedure params generation fix and type refactors

- Simplified procedure's params definition from a tuple an object, since procs are now called with an envelop now

- Refactored procedure related typing to make them more consistent with other CURD types (that usually takes the schema as the first type parameter, and a name as the second)

- Moved detailed procedure's types to "crud-types" where other ORM client detailed types are defined

- Removed some type duplication from hooks side

- Updated the "orm" sample to demonstrate procedures

* fix: disable infinite custom proc queries for now

---------

Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
2026-01-08 11:21:51 +08:00

217 lines
7.5 KiB
TypeScript

import type { ClientContract } from '@zenstackhq/orm';
import { createTestClient } from '@zenstackhq/testtools';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { schema } from '../schemas/procedures/schema';
import type { User } from '../schemas/procedures/models';
describe('Procedures tests', () => {
let client: ClientContract<typeof schema>;
beforeEach(async () => {
client = await createTestClient(schema, {
procedures: {
// Query procedure that returns a single User
getUser: async ({ client, args: { id } }) => {
return await client.user.findUniqueOrThrow({
where: { id },
});
},
// Query procedure that returns an array of Users
listUsers: async ({ client }) => {
return await client.user.findMany();
},
// Mutation procedure that creates a User
signUp: async ({ client, args: { name, role } }) => {
return await client.user.create({
data: {
name,
role,
},
});
},
// Query procedure that returns Void
setAdmin: async ({ client, args: { userId } }) => {
await client.user.update({
where: { id: userId },
data: { role: 'ADMIN' },
});
},
// Query procedure that returns a custom type
getOverview: async ({ client }) => {
const userIds = await client.user.findMany({ select: { id: true } });
const total = await client.user.count();
return {
userIds: userIds.map((u) => u.id),
total,
roles: ['ADMIN', 'USER'],
meta: { hello: 'world' },
};
},
createMultiple: async ({ client, args: { names } }) => {
return await client.$transaction(async (tx) => {
const createdUsers: User[] = [];
for (const name of names) {
const user = await tx.user.create({
data: { name },
});
createdUsers.push(user);
}
return createdUsers;
});
},
},
});
});
afterEach(async () => {
await client?.$disconnect();
});
it('works with query proc with parameters', async () => {
// Create a user first
const created = await client.user.create({
data: {
name: 'Alice',
role: 'USER',
},
});
// Call the procedure
const result = await client.$procs.getUser({ args: { id: created.id } });
expect(result).toMatchObject({
id: created.id,
name: 'Alice',
role: 'USER',
});
});
it('works with query proc without parameters', async () => {
// Create multiple users
await client.user.create({
data: { name: 'Alice', role: 'USER' },
});
await client.user.create({
data: { name: 'Bob', role: 'ADMIN' },
});
await client.user.create({
data: { name: 'Charlie', role: 'USER' },
});
const result = await client.$procs.listUsers();
expect(result).toHaveLength(3);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Alice', role: 'USER' }),
expect.objectContaining({ name: 'Bob', role: 'ADMIN' }),
expect.objectContaining({ name: 'Charlie', role: 'USER' }),
]),
);
});
it('works with mutation with parameters', async () => {
const result = await client.$procs.signUp({ args: { name: 'Alice' } });
expect(result).toMatchObject({
id: expect.any(Number),
name: 'Alice',
role: 'USER',
});
// Verify user was created in database
const users = await client.user.findMany();
expect(users).toHaveLength(1);
expect(users[0]).toMatchObject({
name: 'Alice',
role: 'USER',
});
// accepts optional role parameter
const result1 = await client.$procs.signUp({
args: {
name: 'Bob',
role: 'ADMIN',
},
});
expect(result1).toMatchObject({
id: expect.any(Number),
name: 'Bob',
role: 'ADMIN',
});
// Verify user was created with correct role
const user1 = await client.user.findUnique({
where: { id: result1.id },
});
expect(user1?.role).toBe('ADMIN');
});
it('works with mutation proc that returns void', async () => {
// Create a regular user
const user = await client.user.create({
data: { name: 'Alice', role: 'USER' },
});
expect(user.role).toBe('USER');
// Call setAdmin procedure
const result = await client.$procs.setAdmin({ args: { userId: user.id } });
// Procedure returns void
expect(result).toBeUndefined();
// Verify user role was updated
const updated = await client.user.findUnique({
where: { id: user.id },
});
expect(updated?.role).toBe('ADMIN');
});
it('works with procedure returning custom type', async () => {
await client.user.create({ data: { name: 'Alice', role: 'USER' } });
await client.user.create({ data: { name: 'Bob', role: 'ADMIN' } });
const result = await client.$procs.getOverview();
expect(result.total).toBe(2);
expect(result.userIds).toHaveLength(2);
expect(result.roles).toEqual(expect.arrayContaining(['ADMIN', 'USER']));
expect(result.meta).toEqual({ hello: 'world' });
});
it('works with transactional mutation procs', async () => {
// unique constraint violation should rollback the transaction
await expect(client.$procs.createMultiple({ args: { names: ['Alice', 'Alice'] } })).rejects.toThrow();
await expect(client.user.count()).resolves.toBe(0);
// successful transaction
await expect(client.$procs.createMultiple({ args: { names: ['Alice', 'Bob'] } })).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Bob' }),
]),
);
});
it('respects the outer transaction context', async () => {
// outer client creates a transaction
await expect(
client.$transaction(async (tx) => {
await tx.$procs.signUp({ args: { name: 'Alice' } });
await tx.$procs.signUp({ args: { name: 'Alice' } });
}),
).rejects.toThrow();
await expect(client.user.count()).resolves.toBe(0);
// without transaction
await client.$procs.signUp({ args: { name: 'Alice' } });
await expect(client.$procs.signUp({ args: { name: 'Alice' } })).rejects.toThrow();
await expect(client.user.count()).resolves.toBe(1);
});
});