alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,630 lines (1,340 loc) • 54.5 kB
text/typescript
import { $hook, type Alepha, type Static, z } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { expect } from "vitest";
import { DbEntityNotFoundError } from "../core/errors/DbEntityNotFoundError.ts";
import { $entity, $repository, db } from "../core/index.ts";
import { bigEntity } from "./fixtures/bigEntitySchema.ts";
import type { InsertUserEntity } from "./fixtures/userEntitySchema.ts";
import { userEntity } from "./fixtures/userEntitySchema.ts";
// ============================================================================
// Shared entity definitions for multiple tests
// ============================================================================
class PgAttrApp {
users = $repository(userEntity);
big = $repository(bigEntity);
create = (data: InsertUserEntity) => this.users.create(data);
}
// ============================================================================
// testPgAttr
// ============================================================================
export const testPgAttr = async (alepha: Alepha) => {
const app = alepha.inject(PgAttrApp);
await alepha.start();
const entity = await app.users.create({
suspect: "hey",
name: "John",
profile: {
age: 30,
},
} as any);
expect((entity as any).suspect).toBeUndefined();
expect(entity.name).toEqual("John");
expect(entity.profile.age).toEqual(30);
};
// ============================================================================
// testAllTypes
// ============================================================================
export const testAllTypes = async (alepha: Alepha) => {
const app = alepha.inject(PgAttrApp);
await alepha.start();
const data: Static<typeof bigEntity.insertSchema> = {
type: "big_entity",
a: "a",
b: 1.111,
date: "2024-01-01",
c: 2,
d: true,
e: {
a: "a",
b: 1,
c: 2,
d: true,
e: {
a: "a",
b: 1,
c: 2,
d: true,
j: [
{
a: "a",
b: 1,
c: 2,
d: true,
e: {
a: "a",
b: 1,
c: 2,
d: true,
},
},
],
},
},
f: ["a", "b"],
g: [1.111],
h: [2, 1],
i: [true],
j: [
{
a: "a",
b: 1,
c: 2,
d: true,
e: {
a: "a",
b: 1,
c: 2,
d: true,
},
},
],
k: alepha.inject(DateTimeProvider).nowISOString(),
l: "123e4567-e89b-12d3-a456-426614174000",
m: "a" as const,
};
const entity = await app.big.create(data);
expect(entity).toEqual({
id: entity.id,
...data,
});
const it = await app.big.findMany({
where: {
type: "big_entity",
},
});
expect(it).toHaveLength(1);
};
// ============================================================================
// testBasicCrud
// ============================================================================
class CrudApp {
users = $repository(
$entity({
name: "users",
schema: z.object({
id: db.primaryKey(),
createdAt: db.createdAt(),
updatedAt: db.updatedAt(),
name: z.text(),
profile: z.object({
age: z.number(),
}),
role: db.default(z.text(), "user"),
}),
}),
);
}
export const testBasicCrud = async (alepha: Alepha) => {
const app = alepha.inject(CrudApp);
await alepha.start();
// ========================================
// CREATE OPERATIONS
// ========================================
// Test: create single entity
const createdUser = await app.users.create({
name: "Alice",
profile: { age: 30 },
role: "admin",
});
expect(createdUser.id).toBeDefined();
expect(createdUser.name).toBe("Alice");
expect(createdUser.profile?.age).toBe(30);
expect(createdUser.role).toBe("admin");
const aliceId = createdUser.id;
// Test: createMany
const manyUsers: InsertUserEntity[] = [
{ name: "Bob", profile: { age: 25 }, role: "user" },
{ name: "Charlie", profile: { age: 35 }, role: "admin" },
{ name: "David", profile: { age: 28 }, role: "user" },
{ name: "Eve", profile: { age: 32 }, role: "moderator" },
];
const createdMany = await app.users.createMany(manyUsers);
expect(createdMany).toHaveLength(4);
expect(createdMany[0].name).toBe("Bob");
expect(createdMany[1].name).toBe("Charlie");
expect(createdMany[2].name).toBe("David");
expect(createdMany[3].name).toBe("Eve");
// Test: createMany with empty array
const emptyResult = await app.users.createMany([]);
expect(emptyResult).toHaveLength(0);
// ========================================
// FIND OPERATIONS
// ========================================
// Test: find all
const allUsers = await app.users.findMany();
expect(allUsers).toHaveLength(5);
// Test: find with where clause (exact match)
const adminUsers = await app.users.findMany({
where: { role: { eq: "admin" } },
});
expect(adminUsers).toHaveLength(2);
expect(adminUsers.every((u) => u.role === "admin")).toBe(true);
// Test: find with limit
const limitedUsers = await app.users.findMany({ limit: 2 });
expect(limitedUsers).toHaveLength(2);
// Test: find with offset
const offsetUsers = await app.users.findMany({ offset: 3 });
expect(offsetUsers).toHaveLength(2);
// Test: find with limit and offset
const paginatedUsers = await app.users.findMany({ limit: 2, offset: 1 });
expect(paginatedUsers).toHaveLength(2);
// Test: findOne (returns undefined when not found)
const foundAlice = await app.users.findOne({
where: { name: { eq: "Alice" } },
});
expect(foundAlice).toBeDefined();
expect(foundAlice!.id).toBe(aliceId);
expect(foundAlice!.name).toBe("Alice");
// Test: findOne returns undefined when not found
const notFound = await app.users.findOne({
where: { name: { eq: "NonExistent" } },
});
expect(notFound).toBeUndefined();
// Test: getOne throws when not found
await expect(
app.users.getOne({ where: { name: { eq: "NonExistent" } } }),
).rejects.toThrowError(DbEntityNotFoundError);
// Test: findById (returns undefined when not found)
const userById = await app.users.findById(aliceId);
expect(userById).toBeDefined();
expect(userById!.id).toBe(aliceId);
expect(userById!.name).toBe("Alice");
// Test: findById returns undefined when not found
const notFoundById = await app.users.findById(999999);
expect(notFoundById).toBeUndefined();
// Test: getById throws when not found
await expect(app.users.getById(999999)).rejects.toThrowError(
DbEntityNotFoundError,
);
// ========================================
// COUNT OPERATIONS
// ========================================
// Test: count all
const totalCount = await app.users.count();
expect(totalCount).toBe(5);
// Test: count with where clause
const adminCount = await app.users.count({ role: { eq: "admin" } });
expect(adminCount).toBe(2);
const userCount = await app.users.count({ role: { eq: "user" } });
expect(userCount).toBe(2);
const moderatorCount = await app.users.count({ role: { eq: "moderator" } });
expect(moderatorCount).toBe(1);
// ========================================
// UPDATE OPERATIONS
// ========================================
// Test: updateOne
const updatedAlice = await app.users.updateOne(
{ id: { eq: aliceId } },
{ name: "Alice Updated", role: "admin" }, // Keep role since defaults apply
);
expect(updatedAlice.id).toBe(aliceId);
expect(updatedAlice.name).toBe("Alice Updated");
expect(updatedAlice.role).toBe("admin");
// Test: updateOne throws when not found
await expect(
app.users.updateOne({ id: { eq: 999999 } }, { name: "Should Fail" }),
).rejects.toThrowError(DbEntityNotFoundError);
// Test: updateById
const updatedByIdAlice = await app.users.updateById(aliceId, {
role: "superadmin",
});
expect(updatedByIdAlice.id).toBe(aliceId);
expect(updatedByIdAlice.role).toBe("superadmin");
expect(updatedByIdAlice.name).toBe("Alice Updated"); // Previous update persisted
// Verify update persisted
const verifyAlice = await app.users.getById(aliceId);
expect(verifyAlice.name).toBe("Alice Updated");
expect(verifyAlice.role).toBe("superadmin");
// Test: updateMany
const updatedUserIds = await app.users.updateMany(
{ role: { eq: "user" } },
{ role: "member" },
);
expect(updatedUserIds).toHaveLength(2);
// Verify updateMany worked
const members = await app.users.findMany({
where: { role: { eq: "member" } },
});
expect(members).toHaveLength(2);
expect(members.every((u) => u.role === "member")).toBe(true);
// Test: save
const userToSave = await app.users.getOne({
where: { name: { eq: "Charlie" } },
});
userToSave.name = "Charlie Saved";
userToSave.profile = { age: 36 };
await app.users.save(userToSave);
const savedUser = await app.users.getById(userToSave.id);
expect(savedUser.name).toBe("Charlie Saved");
expect(savedUser.profile?.age).toBe(36);
// ========================================
// DELETE OPERATIONS
// ========================================
// Test: deleteOne
const bob = await app.users.getOne({ where: { name: { eq: "Bob" } } });
const deletedOneIds = await app.users.deleteOne({ id: { eq: bob.id } });
expect(deletedOneIds).toHaveLength(1);
expect(deletedOneIds[0]).toBe(bob.id);
// Verify bob is deleted
const usersAfterDeleteOne = await app.users.findMany();
expect(usersAfterDeleteOne).toHaveLength(4);
expect(usersAfterDeleteOne.every((u) => u.name !== "Bob")).toBe(true);
// Test: deleteById
const eve = await app.users.getOne({ where: { name: { eq: "Eve" } } });
const deletedByIdResult = await app.users.deleteById(eve.id);
expect(deletedByIdResult).toHaveLength(1);
expect(deletedByIdResult[0]).toBe(eve.id);
// Test: deleteById throws when not found
await expect(app.users.deleteById(999999)).rejects.toThrowError(
DbEntityNotFoundError,
);
// Verify eve is deleted
const usersAfterDeleteById = await app.users.findMany();
expect(usersAfterDeleteById).toHaveLength(3);
// Test: destroy (delete by entity)
const david = await app.users.getOne({ where: { name: { eq: "David" } } });
const destroyedIds = await app.users.destroy(david);
expect(destroyedIds).toHaveLength(1);
expect(destroyedIds[0]).toBe(david.id);
// Verify david is deleted
const usersAfterDestroy = await app.users.findMany();
expect(usersAfterDestroy).toHaveLength(2);
// Test: deleteMany
const deletedManyIds = await app.users.deleteMany({
role: { eq: "superadmin" },
});
expect(deletedManyIds).toHaveLength(1);
expect(deletedManyIds[0]).toBe(aliceId);
// Verify only Charlie remains
const remainingUsers = await app.users.findMany();
expect(remainingUsers).toHaveLength(1);
expect(remainingUsers[0].name).toBe("Charlie Saved");
// Test: clear (delete all)
await app.users.createMany([
{ name: "Test1", profile: { age: 20 }, role: "user" },
{ name: "Test2", profile: { age: 21 }, role: "user" },
{ name: "Test3", profile: { age: 22 }, role: "admin" },
]);
const beforeClearCount = await app.users.count();
expect(beforeClearCount).toBe(4);
const clearedIds = await app.users.clear({ force: true });
expect(clearedIds).toHaveLength(4);
const afterClearCount = await app.users.count();
expect(afterClearCount).toBe(0);
const emptyFind = await app.users.findMany();
expect(emptyFind).toHaveLength(0);
};
// ============================================================================
// testRepositoryHooks
// ============================================================================
class HookTracker {
events: Array<{ name: string; data: any }> = [];
clear() {
this.events = [];
}
record(name: string, data: any) {
this.events.push({ name, data });
}
find(name: string) {
return this.events.filter((e) => e.name === name);
}
findLast(name: string) {
const events = this.find(name);
return events[events.length - 1];
}
}
class HooksApp {
tracker = new HookTracker();
users = $repository(
$entity({
name: "users",
schema: z.object({
id: db.primaryKey(),
name: z.text(),
email: z.text(),
}),
}),
);
// Hook: Create Before
createBefore = $hook({
on: "repository:create:before",
handler: async ({ data, tableName }) => {
this.tracker.record("create:before", { tableName, data });
},
});
// Hook: Create After
createAfter = $hook({
on: "repository:create:after",
handler: async ({ data, entity, tableName }) => {
this.tracker.record("create:after", { tableName, data, entity });
},
});
// Hook: Read Before
readBefore = $hook({
on: "repository:read:before",
handler: async ({ query, tableName }) => {
this.tracker.record("read:before", { tableName, query });
},
});
// Hook: Read After
readAfter = $hook({
on: "repository:read:after",
handler: async ({ query, entities, tableName }) => {
this.tracker.record("read:after", { tableName, query, entities });
},
});
// Hook: Update Before
updateBefore = $hook({
on: "repository:update:before",
handler: async ({ where, data, tableName }) => {
this.tracker.record("update:before", { tableName, where, data });
},
});
// Hook: Update After
updateAfter = $hook({
on: "repository:update:after",
handler: async ({ where, data, entities, tableName }) => {
this.tracker.record("update:after", { tableName, where, data, entities });
},
});
// Hook: Delete Before
deleteBefore = $hook({
on: "repository:delete:before",
handler: async ({ where, tableName }) => {
this.tracker.record("delete:before", { tableName, where });
},
});
// Hook: Delete After
deleteAfter = $hook({
on: "repository:delete:after",
handler: async ({ where, ids, tableName }) => {
this.tracker.record("delete:after", { tableName, where, ids });
},
});
}
export const testRepositoryHooks = async (alepha: Alepha) => {
const app = alepha.inject(HooksApp);
await alepha.start();
// ========================================
// CREATE HOOKS
// ========================================
// Test: create hook (single entity)
app.tracker.clear();
const user1 = await app.users.create({
name: "Alice",
email: "alice@example.com",
});
expect(app.tracker.find("create:before")).toHaveLength(1);
expect(app.tracker.find("create:after")).toHaveLength(1);
const createBeforeEvent = app.tracker.findLast("create:before");
expect(createBeforeEvent.data.tableName).toBe("users");
expect(createBeforeEvent.data.data.name).toBe("Alice");
const createAfterEvent = app.tracker.findLast("create:after");
expect(createAfterEvent.data.tableName).toBe("users");
expect(createAfterEvent.data.entity.id).toBe(user1.id);
expect(createAfterEvent.data.entity.name).toBe("Alice");
// Test: createMany hook
app.tracker.clear();
const users = await app.users.createMany([
{ name: "Bob", email: "bob@example.com" },
{ name: "Charlie", email: "charlie@example.com" },
]);
expect(app.tracker.find("create:before")).toHaveLength(1);
expect(app.tracker.find("create:after")).toHaveLength(1);
const createManyBeforeEvent = app.tracker.findLast("create:before");
expect(createManyBeforeEvent.data.tableName).toBe("users");
expect(createManyBeforeEvent.data.data).toHaveLength(2);
const createManyAfterEvent = app.tracker.findLast("create:after");
expect(createManyAfterEvent.data.tableName).toBe("users");
expect(createManyAfterEvent.data.entity).toHaveLength(2);
expect(createManyAfterEvent.data.entity[0].name).toBe("Bob");
expect(createManyAfterEvent.data.entity[1].name).toBe("Charlie");
// ========================================
// READ HOOKS
// ========================================
// Test: find hook
app.tracker.clear();
const allUsers = await app.users.findMany();
expect(app.tracker.find("read:before")).toHaveLength(1);
expect(app.tracker.find("read:after")).toHaveLength(1);
const readBeforeEvent = app.tracker.findLast("read:before");
expect(readBeforeEvent.data.tableName).toBe("users");
expect(readBeforeEvent.data.query).toBeDefined();
const readAfterEvent = app.tracker.findLast("read:after");
expect(readAfterEvent.data.tableName).toBe("users");
expect(readAfterEvent.data.entities).toHaveLength(3);
// Test: findOne hook (uses find internally)
app.tracker.clear();
await app.users.findOne({ where: { name: { eq: "Alice" } } });
expect(app.tracker.find("read:before")).toHaveLength(1);
expect(app.tracker.find("read:after")).toHaveLength(1);
// Test: findById hook (uses find internally)
app.tracker.clear();
await app.users.findById(user1.id);
expect(app.tracker.find("read:before")).toHaveLength(1);
expect(app.tracker.find("read:after")).toHaveLength(1);
// Test: paginate hook (uses find internally)
app.tracker.clear();
await app.users.paginate({ page: 0, size: 10 });
// paginate calls find once
expect(app.tracker.find("read:before")).toHaveLength(1);
expect(app.tracker.find("read:after")).toHaveLength(1);
// ========================================
// UPDATE HOOKS
// ========================================
// Test: updateOne hook
app.tracker.clear();
await app.users.updateOne(
{ id: { eq: user1.id } },
{ name: "Alice Updated" },
);
expect(app.tracker.find("update:before")).toHaveLength(1);
expect(app.tracker.find("update:after")).toHaveLength(1);
const updateBeforeEvent = app.tracker.findLast("update:before");
expect(updateBeforeEvent.data.tableName).toBe("users");
expect(updateBeforeEvent.data.data.name).toBe("Alice Updated");
const updateAfterEvent = app.tracker.findLast("update:after");
expect(updateAfterEvent.data.tableName).toBe("users");
expect(updateAfterEvent.data.entities).toHaveLength(1);
expect(updateAfterEvent.data.entities[0].name).toBe("Alice Updated");
// Test: updateById hook (uses updateOne internally)
app.tracker.clear();
await app.users.updateById(user1.id, { email: "alice.new@example.com" });
expect(app.tracker.find("update:before")).toHaveLength(1);
expect(app.tracker.find("update:after")).toHaveLength(1);
// Test: updateMany hook
app.tracker.clear();
const allBeforeUpdate = await app.users.findMany();
const bobAndCharlie = allBeforeUpdate.filter(
(u) => u.name === "Bob" || u.name === "Charlie",
);
await app.users.updateMany(
{
or: [{ name: { eq: "Bob" } }, { name: { eq: "Charlie" } }],
},
{ email: "updated@example.com" },
);
expect(app.tracker.find("update:before")).toHaveLength(1);
expect(app.tracker.find("update:after")).toHaveLength(1);
const updateManyAfterEvent = app.tracker.findLast("update:after");
expect(updateManyAfterEvent.data.entities.length).toBeGreaterThanOrEqual(2);
// Test: save hook (uses updateOne internally)
app.tracker.clear();
const userToSave = await app.users.getById(user1.id);
userToSave.name = "Alice Saved";
await app.users.save(userToSave);
// save calls find (read) once, and updateOne (update) once
expect(app.tracker.find("read:before").length).toBeGreaterThanOrEqual(1);
expect(app.tracker.find("read:after").length).toBeGreaterThanOrEqual(1);
expect(app.tracker.find("update:before")).toHaveLength(1);
expect(app.tracker.find("update:after")).toHaveLength(1);
// ========================================
// DELETE HOOKS
// ========================================
// Test: deleteMany hook
app.tracker.clear();
const bobUser = bobAndCharlie.find((u) => u.name === "Bob");
if (!bobUser) throw new Error("Bob not found");
await app.users.deleteMany({ id: { eq: bobUser.id } });
expect(app.tracker.find("delete:before")).toHaveLength(1);
expect(app.tracker.find("delete:after")).toHaveLength(1);
const deleteBeforeEvent = app.tracker.findLast("delete:before");
expect(deleteBeforeEvent.data.tableName).toBe("users");
const deleteAfterEvent = app.tracker.findLast("delete:after");
expect(deleteAfterEvent.data.tableName).toBe("users");
expect(deleteAfterEvent.data.ids).toHaveLength(1);
expect(deleteAfterEvent.data.ids[0]).toBe(bobUser.id);
// Test: deleteOne hook (uses deleteMany internally)
app.tracker.clear();
const charlieUser = bobAndCharlie.find((u) => u.name === "Charlie");
if (!charlieUser) throw new Error("Charlie not found");
await app.users.deleteOne({ id: { eq: charlieUser.id } });
expect(app.tracker.find("delete:before")).toHaveLength(1);
expect(app.tracker.find("delete:after")).toHaveLength(1);
// Test: deleteById hook (uses deleteMany internally)
app.tracker.clear();
const remainingUsers = await app.users.findMany();
const lastUser = remainingUsers[0];
await app.users.deleteById(lastUser.id);
expect(app.tracker.find("delete:before")).toHaveLength(1);
expect(app.tracker.find("delete:after")).toHaveLength(1);
// ========================================
// HOOK ORDER VERIFICATION
// ========================================
// Test: hooks fire in correct order (before -> after)
app.tracker.clear();
await app.users.create({ name: "Test", email: "test@example.com" });
const allEvents = app.tracker.events.map((e) => e.name);
const createBeforeIndex = allEvents.indexOf("create:before");
const createAfterIndex = allEvents.indexOf("create:after");
expect(createBeforeIndex).toBeLessThan(createAfterIndex);
};
// ============================================================================
// testOrderByModes
// ============================================================================
class OrderByApp {
users = $repository(userEntity);
}
export const testOrderByModes = async (alepha: Alepha) => {
const app = alepha.inject(OrderByApp);
await alepha.start();
// Create test users with different names and roles
const users: InsertUserEntity[] = [
{ name: "Charlie", profile: { age: 25 }, role: "admin" },
{ name: "Alice", profile: { age: 30 }, role: "user" },
{ name: "Bob", profile: { age: 20 }, role: "admin" },
{ name: "David", profile: { age: 35 }, role: "user" },
];
for (const user of users) {
await app.users.create(user);
}
// MODE 1: String (defaults to ASC)
const usersMode1 = await app.users.findMany({
orderBy: "name",
});
expect(usersMode1.length).toBe(4);
expect(usersMode1[0].name).toBe("Alice");
expect(usersMode1[1].name).toBe("Bob");
expect(usersMode1[2].name).toBe("Charlie");
expect(usersMode1[3].name).toBe("David");
// MODE 2: Single object with direction ASC
const usersMode2Asc = await app.users.findMany({
orderBy: { column: "name", direction: "asc" },
});
expect(usersMode2Asc.length).toBe(4);
expect(usersMode2Asc[0].name).toBe("Alice");
expect(usersMode2Asc[1].name).toBe("Bob");
expect(usersMode2Asc[2].name).toBe("Charlie");
expect(usersMode2Asc[3].name).toBe("David");
// MODE 2: Single object with direction DESC
const usersMode2Desc = await app.users.findMany({
orderBy: { column: "name", direction: "desc" },
});
expect(usersMode2Desc.length).toBe(4);
expect(usersMode2Desc[0].name).toBe("David");
expect(usersMode2Desc[1].name).toBe("Charlie");
expect(usersMode2Desc[2].name).toBe("Bob");
expect(usersMode2Desc[3].name).toBe("Alice");
// MODE 2: Single object without direction (defaults to ASC)
const usersMode2Default = await app.users.findMany({
orderBy: { column: "name" },
});
expect(usersMode2Default.length).toBe(4);
expect(usersMode2Default[0].name).toBe("Alice");
expect(usersMode2Default[3].name).toBe("David");
// MODE 3: Array with multiple columns
const usersMode3Multi = await app.users.findMany({
orderBy: [
{ column: "role", direction: "asc" },
{ column: "name", direction: "desc" },
],
});
expect(usersMode3Multi.length).toBe(4);
// First by role ASC (admin, admin, user, user)
// Then by name DESC within each role
expect(usersMode3Multi[0].role).toBe("admin");
expect(usersMode3Multi[0].name).toBe("Charlie"); // admin, Charlie
expect(usersMode3Multi[1].role).toBe("admin");
expect(usersMode3Multi[1].name).toBe("Bob"); // admin, Bob
expect(usersMode3Multi[2].role).toBe("user");
expect(usersMode3Multi[2].name).toBe("David"); // user, David
expect(usersMode3Multi[3].role).toBe("user");
expect(usersMode3Multi[3].name).toBe("Alice"); // user, Alice
// MODE 3: Array with default direction
const usersMode3Default = await app.users.findMany({
orderBy: [{ column: "role" }, { column: "name" }],
});
expect(usersMode3Default.length).toBe(4);
// Both ASC by default
expect(usersMode3Default[0].role).toBe("admin");
expect(usersMode3Default[0].name).toBe("Bob"); // admin, Bob
expect(usersMode3Default[1].role).toBe("admin");
expect(usersMode3Default[1].name).toBe("Charlie"); // admin, Charlie
// MODE 3: Array with single column
const usersMode3Single = await app.users.findMany({
orderBy: [{ column: "name", direction: "desc" }],
});
expect(usersMode3Single.length).toBe(4);
expect(usersMode3Single[0].name).toBe("David");
expect(usersMode3Single[3].name).toBe("Alice");
// Clean up
await app.users.clear({ force: true });
};
// ============================================================================
// testPaginationSort
// ============================================================================
class PaginationSortApp {
users = $repository(userEntity);
}
export const testPaginationSort = async (alepha: Alepha) => {
const app = alepha.inject(PaginationSortApp);
await alepha.start();
// Create test users with different names and roles
const users: InsertUserEntity[] = [
{ name: "Charlie", profile: { age: 25 }, role: "admin" },
{ name: "Alice", profile: { age: 30 }, role: "user" },
{ name: "Bob", profile: { age: 20 }, role: "admin" },
{ name: "David", profile: { age: 35 }, role: "user" },
];
for (const user of users) {
await app.users.create(user);
}
// Test 1: Single column ASC (no prefix)
const result1 = await app.users.paginate({ sort: "name" });
expect(result1.content.length).toBe(4);
expect(result1.content[0].name).toBe("Alice");
expect(result1.content[1].name).toBe("Bob");
expect(result1.content[2].name).toBe("Charlie");
expect(result1.content[3].name).toBe("David");
// Test 2: Single column DESC (with - prefix)
const result2 = await app.users.paginate({ sort: "-name" });
expect(result2.content.length).toBe(4);
expect(result2.content[0].name).toBe("David");
expect(result2.content[1].name).toBe("Charlie");
expect(result2.content[2].name).toBe("Bob");
expect(result2.content[3].name).toBe("Alice");
// Test 3: Multiple columns - role ASC, name DESC
const result3 = await app.users.paginate({ sort: "role,-name" });
expect(result3.content.length).toBe(4);
// First by role ASC (admin, admin, user, user)
// Then by name DESC within each role
expect(result3.content[0].role).toBe("admin");
expect(result3.content[0].name).toBe("Charlie"); // admin, Charlie
expect(result3.content[1].role).toBe("admin");
expect(result3.content[1].name).toBe("Bob"); // admin, Bob
expect(result3.content[2].role).toBe("user");
expect(result3.content[2].name).toBe("David"); // user, David
expect(result3.content[3].role).toBe("user");
expect(result3.content[3].name).toBe("Alice"); // user, Alice
// Test 4: Multiple columns - both ASC
const result4 = await app.users.paginate({ sort: "role,name" });
expect(result4.content.length).toBe(4);
// Both ASC
expect(result4.content[0].role).toBe("admin");
expect(result4.content[0].name).toBe("Bob"); // admin, Bob
expect(result4.content[1].role).toBe("admin");
expect(result4.content[1].name).toBe("Charlie"); // admin, Charlie
expect(result4.content[2].role).toBe("user");
expect(result4.content[2].name).toBe("Alice"); // user, Alice
expect(result4.content[3].role).toBe("user");
expect(result4.content[3].name).toBe("David"); // user, David
// Test 5: Multiple columns - both DESC
const result5 = await app.users.paginate({ sort: "-role,-name" });
expect(result5.content.length).toBe(4);
// Both DESC
expect(result5.content[0].role).toBe("user");
expect(result5.content[0].name).toBe("David"); // user, David
expect(result5.content[1].role).toBe("user");
expect(result5.content[1].name).toBe("Alice"); // user, Alice
expect(result5.content[2].role).toBe("admin");
expect(result5.content[2].name).toBe("Charlie"); // admin, Charlie
expect(result5.content[3].role).toBe("admin");
expect(result5.content[3].name).toBe("Bob"); // admin, Bob
// Test 6: Pagination with sorting and page size
const result6 = await app.users.paginate(
{ sort: "name", size: 2, page: 0 },
{},
{ count: true },
);
expect(result6.content.length).toBe(2);
expect(result6.content[0].name).toBe("Alice");
expect(result6.content[1].name).toBe("Bob");
expect(result6.page.totalElements).toBe(4);
expect(result6.page.totalPages).toBe(2);
expect(result6.page.isFirst).toBe(true);
expect(result6.page.isLast).toBe(false);
expect(result6.page.sort?.sorted).toBe(true);
expect(result6.page.sort?.fields).toEqual([
{ field: "name", direction: "asc" },
]);
// Test 7: Second page
const result7 = await app.users.paginate(
{ sort: "name", size: 2, page: 1 },
{},
{ count: true },
);
expect(result7.content.length).toBe(2);
expect(result7.content[0].name).toBe("Charlie");
expect(result7.content[1].name).toBe("David");
expect(result7.page.isFirst).toBe(false);
expect(result7.page.isLast).toBe(true);
expect(result7.page.sort?.sorted).toBe(true);
expect(result7.page.sort?.fields).toEqual([
{ field: "name", direction: "asc" },
]);
// Test 8: Sort with spaces (should be trimmed)
const result8 = await app.users.paginate({ sort: " role , -name " });
expect(result8.content.length).toBe(4);
expect(result8.content[0].role).toBe("admin");
expect(result8.content[0].name).toBe("Charlie");
// Clean up
await app.users.clear({ force: true });
};
// ============================================================================
// testSaveWithDeletedFields
// ============================================================================
const dummyEntity = $entity({
name: "dummies",
schema: z.object({
id: db.primaryKey(),
name: z.text().optional(),
}),
});
class SaveApp {
dummies = $repository(dummyEntity);
}
export const testSaveWithDeletedFields = async (alepha: Alepha) => {
const app = alepha.inject(SaveApp);
await alepha.start();
// Create a new entity
const entity = await app.dummies.create({ name: "Test" });
expect(await app.dummies.getById(entity.id).then((it) => it.name)).toBe(
"Test",
);
// Update the entity to set name to undefined (delete the field)
entity.name = undefined;
await app.dummies.save(entity);
expect(entity.name).toBeUndefined();
expect(
await app.dummies.getById(entity.id).then((it) => it.name),
).toBeUndefined();
};
// ============================================================================
// testTransactionThrowsWhenUnsupported
// ============================================================================
export const testTransactionThrowsWhenUnsupported = async (alepha: Alepha) => {
class App {
users = $repository(userEntity);
}
const app = alepha.inject(App);
await alepha.start();
// Temporarily override supportsTransactions
const provider = (app.users as any).provider;
const original = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(provider),
"supportsTransactions",
);
Object.defineProperty(provider, "supportsTransactions", {
get: () => false,
configurable: true,
});
try {
await expect(
app.users.transaction(async () => {
// should never reach here
}),
).rejects.toThrow("Transactions are not supported");
} finally {
// Restore
if (original) {
Object.defineProperty(
Object.getPrototypeOf(provider),
"supportsTransactions",
original,
);
} else {
// biome-ignore lint/performance/noDelete: setting to undefined fails because the prototype has a getter
delete provider.supportsTransactions;
}
}
};
// ============================================================================
// testSaveWithCustomPrimaryKey
// ============================================================================
const customPkEntity = $entity({
name: "custom_pk_items",
schema: z.object({
code: db.primaryKey(z.uuid()),
label: z.text(),
}),
});
class CustomPkApp {
items = $repository(customPkEntity);
}
export const testSaveWithCustomPrimaryKey = async (alepha: Alepha) => {
const app = alepha.inject(CustomPkApp);
await alepha.start();
const entity = await app.items.create({ label: "Original" });
expect(entity.code).toBeDefined();
expect(entity.label).toBe("Original");
entity.label = "Updated";
await app.items.save(entity);
const updated = await app.items.getById(entity.code);
expect(updated.label).toBe("Updated");
};
// ============================================================================
// testUpsert
// ============================================================================
const productEntity = $entity({
name: "products",
schema: z.object({
id: db.primaryKey(z.uuid()),
createdAt: db.createdAt(),
updatedAt: db.updatedAt(),
sku: z.text(),
name: z.text(),
price: z.number(),
}),
indexes: [{ column: "sku", unique: true }],
});
class UpsertApp {
products = $repository(productEntity);
}
export const testUpsert = async (alepha: Alepha) => {
const app = alepha.inject(UpsertApp);
await alepha.start();
// ========================================
// INSERT (no conflict)
// ========================================
const created = await app.products.upsert({
id: "00000000-0000-0000-0000-000000000001",
sku: "WIDGET-1",
name: "Widget",
price: 9.99,
});
expect(created.id).toBe("00000000-0000-0000-0000-000000000001");
expect(created.sku).toBe("WIDGET-1");
expect(created.name).toBe("Widget");
expect(created.price).toBe(9.99);
// ========================================
// UPDATE (conflict on primary key)
// ========================================
const upserted = await app.products.upsert({
id: "00000000-0000-0000-0000-000000000001",
sku: "WIDGET-1",
name: "Widget Pro",
price: 19.99,
});
expect(upserted.id).toBe("00000000-0000-0000-0000-000000000001");
expect(upserted.name).toBe("Widget Pro");
expect(upserted.price).toBe(19.99);
// Verify only one record exists
expect(await app.products.count()).toBe(1);
// ========================================
// UPSERT with custom target (unique column)
// ========================================
const gadget = await app.products.upsert({
id: "00000000-0000-0000-0000-000000000002",
sku: "GADGET-1",
name: "Gadget",
price: 29.99,
});
expect(gadget.name).toBe("Gadget");
// Upsert on sku conflict — new id is ignored, existing row is updated
const gadgetUpdated = await app.products.upsert(
{
id: "00000000-0000-0000-0000-000000000099",
sku: "GADGET-1",
name: "Gadget v2",
price: 39.99,
},
{ target: ["sku"] },
);
expect(gadgetUpdated.id).toBe("00000000-0000-0000-0000-000000000002");
expect(gadgetUpdated.name).toBe("Gadget v2");
expect(gadgetUpdated.price).toBe(39.99);
// Still only 2 records
expect(await app.products.count()).toBe(2);
// ========================================
// UPSERT with custom set (partial update)
// ========================================
// Only update the price on conflict, keep name unchanged
const gadgetPartial = await app.products.upsert(
{
id: "00000000-0000-0000-0000-000000000002",
sku: "GADGET-1",
name: "Should Not Change",
price: 49.99,
},
{
target: ["sku"],
set: { price: 49.99 },
},
);
expect(gadgetPartial.id).toBe("00000000-0000-0000-0000-000000000002");
expect(gadgetPartial.name).toBe("Gadget v2"); // unchanged
expect(gadgetPartial.price).toBe(49.99); // updated
// ========================================
// UPSERT sets updatedAt
// ========================================
const beforeUpdate = gadget.updatedAt;
await new Promise((r) => setTimeout(r, 5));
const gadgetAgain = await app.products.upsert(
{
id: "00000000-0000-0000-0000-000000000002",
sku: "GADGET-1",
name: "Gadget v3",
price: 59.99,
},
{ target: ["sku"] },
);
expect(gadgetAgain.updatedAt).not.toBe(beforeUpdate);
// ========================================
// UPSERT inserts new record via custom target
// ========================================
const newProduct = await app.products.upsert(
{
id: "00000000-0000-0000-0000-000000000003",
sku: "THING-1",
name: "Thing",
price: 5.0,
},
{ target: ["sku"] },
);
expect(newProduct.name).toBe("Thing");
expect(await app.products.count()).toBe(3);
};
// ============================================================================
// testSerialIdOperations
// ============================================================================
const serialTestSchema = z.object({
id: db.primaryKey(),
name: z.text(),
});
const serialTestEntity = $entity({
name: "test",
schema: serialTestSchema,
});
class SerialIdApp {
repository = $repository(serialTestEntity);
}
export const testSerialIdOperations = async (alepha: Alepha) => {
const app = alepha.inject(SerialIdApp);
await alepha.start();
expect(app.repository.id.key).toEqual("id");
const it = await app.repository.create({ name: "test" });
expect(await app.repository.getById(it.id)).toEqual(it);
expect(await app.repository.getById(String(it.id))).toEqual(it);
const update = await app.repository.updateById(it.id, { name: "2" });
expect(await app.repository.getById(it.id)).toEqual(update);
await app.repository.deleteById(it.id);
expect(await app.repository.count()).toBe(0);
};
// ============================================================================
// testUuidIdOperations
// ============================================================================
const uuidSchema = z.object({
uuid: db.uuidPrimaryKey(),
name: z.text(),
});
const uuidEntity = $entity({
name: "test",
schema: uuidSchema,
});
class UuidIdApp {
repository = $repository(uuidEntity);
}
export const testUuidIdOperations = async (alepha: Alepha) => {
const app = alepha.inject(UuidIdApp);
await alepha.start();
expect(app.repository.id.key).toEqual("uuid");
// The id column is a uuid primary key. We assert the introspectable shape
// rather than deep-equaling the schema object: zod lazily installs an own
// `meta` property on a schema the first time `.meta()` is read (which the ORM
// does while building columns), so two structurally-identical uuid schemas no
// longer compare equal by reference/own-keys.
expect(z.schema.format(app.repository.id.type)).toEqual("uuid");
expect(z.schema.format(db.uuidPrimaryKey())).toEqual("uuid");
const it = await app.repository.create({ name: "test" });
expect(await app.repository.getById(it.uuid)).toEqual(it);
const update = await app.repository.updateById(it.uuid, { name: "2" });
expect(await app.repository.getById(it.uuid)).toEqual(update);
await app.repository.deleteById(it.uuid);
expect(await app.repository.count()).toBe(0);
};
// ============================================================================
// testMultipleOperators
// ============================================================================
const rangeSchema = z.object({
id: db.primaryKey(),
name: z.text(),
age: z.number(),
});
const rangeEntity = $entity({
name: "test_range",
schema: rangeSchema,
});
class MultipleOperatorsApp {
repository = $repository(rangeEntity);
}
export const testMultipleOperators = async (alepha: Alepha) => {
const app = alepha.inject(MultipleOperatorsApp);
await alepha.start();
// Create test data
await app.repository.create({ name: "Alice", age: 20 });
await app.repository.create({ name: "Bob", age: 25 });
await app.repository.create({ name: "Charlie", age: 30 });
await app.repository.create({ name: "David", age: 35 });
await app.repository.create({ name: "Eve", age: 40 });
// Test range query with both gte and lte
const results = await app.repository.findMany({
where: {
age: { gte: 25, lte: 35 },
},
});
expect(results).toHaveLength(3);
expect(results.map((r) => r.name).sort()).toEqual([
"Bob",
"Charlie",
"David",
]);
// Test with only gte
const resultsGte = await app.repository.findMany({
where: {
age: { gte: 35 },
},
});
expect(resultsGte).toHaveLength(2);
expect(resultsGte.map((r) => r.name).sort()).toEqual(["David", "Eve"]);
// Test with only lte
const resultsLte = await app.repository.findMany({
where: {
age: { lte: 25 },
},
});
expect(resultsLte).toHaveLength(2);
expect(resultsLte.map((r) => r.name).sort()).toEqual(["Alice", "Bob"]);
};
// ============================================================================
// testCrudWithTimestamps
// ============================================================================
class TimestampsApp {
users = $repository(userEntity);
}
export const testCrudWithTimestamps = async (alepha: Alepha) => {
const app = alepha.inject(TimestampsApp);
const dt = alepha.inject(DateTimeProvider);
await alepha.start();
dt.pause();
await app.users.create({
name: "John",
profile: {
age: 30,
},
});
const [r1] = await app.users.findMany({
where: { name: { eq: "John" } },
});
expect(r1.name).toEqual("John");
expect(r1.createdAt).toBe(r1.updatedAt);
dt.travel(1000);
const r2 = await app.users.updateOne(
{ name: { eq: "John" } },
{
profile: { age: 31 },
},
);
expect(r2.name).toEqual("John");
expect(r2.profile.age).toEqual(31);
expect(r2.createdAt).toBe(r1.createdAt);
expect(r2.updatedAt).not.toBe(r1.updatedAt);
};
// ============================================================================
// testPagination
// ============================================================================
class PaginationApp {
users = $repository(userEntity);
}
export const testPagination = async (alepha: Alepha) => {
const app = alepha.inject(PaginationApp);
await alepha.start();
// Create test data - 25 users
const users: InsertUserEntity[] = [];
for (let i = 1; i <= 25; i++) {
users.push({
name: `User ${i}`,
profile: { age: 20 + i },
role: i % 2 === 0 ? "admin" : "user",
});
}
for (const user of users) {
await app.users.create(user);
}
// Test 1: First page without count
const page1 = await app.users.paginate({ page: 0, size: 10 });
expect(page1.content.length).toBe(10);
expect(page1.page.number).toBe(0);
expect(page1.page.size).toBe(10);
expect(page1.page.offset).toBe(0);
expect(page1.page.numberOfElements).toBe(10);
expect(page1.page.isEmpty).toBe(false);
expect(page1.page.isFirst).toBe(true);
expect(page1.page.isLast).toBe(false);
expect(page1.page.totalElements).toBeUndefined();
expect(page1.page.totalPages).toBeUndefined();
// Test 2: First page with count
const page2 = await app.users.paginate(
{ page: 0, size: 10 },
{},
{ count: true },
);
expect(page2.content.length).toBe(10);
expect(page2.page.number).toBe(0);
expect(page2.page.size).toBe(10);
expect(page2.page.offset).toBe(0);
expect(page2.page.numberOfElements).toBe(10);
expect(page2.page.isEmpty).toBe(false);
expect(page2.page.isFirst).toBe(true);
expect(page2.page.isLast).toBe(false);
expect(page2.page.totalElements).toBe(25);
expect(page2.page.totalPages).toBe(3);
// Test 3: Middle page with count
const page3 = await app.users.paginate(
{ page: 1, size: 10 },
{},
{ count: true },
);
expect(page3.content.length).toBe(10);
expect(page3.page.number).toBe(1);
expect(page3.page.size).toBe(10);
expect(page3.page.offset).toBe(10);
expect(page3.page.numberOfElements).toBe(10);
expect(page3.page.isEmpty).toBe(false);
expect(page3.page.isFirst).toBe(false);
expect(page3.page.isLast).toBe(false);
expect(page3.page.totalElements).toBe(25);
expect(page3.page.totalPages).toBe(3);
// Test 4: Last page with count
const page4 = await app.users.paginate(
{ page: 2, size: 10 },
{},
{ count: true },
);
expect(page4.content.length).toBe(5);
expect(page4.page.number).toBe(2);
expect(page4.page.size).toBe(10);
expect(page4.page.offset).toBe(20);
expect(page4.page.numberOfElements).toBe(5);
expect(page4.page.isEmpty).toBe(false);
expect(page4.page.isFirst).toBe(false);
expect(page4.page.isLast).toBe(true);
expect(page4.page.totalElements).toBe(25);
expect(page4.page.totalPages).toBe(3);
// Test 5: Empty page (beyond last page)
const page5 = await app.users.paginate(
{ page: 3, size: 10 },
{},
{ count: true },
);
expect(page5.content.length).toBe(0);
expect(page5.page.number).toBe(3);
expect(page5.page.size).toBe(10);
expect(page5.page.offset).toBe(30);
expect(page5.page.numberOfElements).toBe(0);
expect(page5.page.isEmpty).toBe(true);
expect(page5.page.isFirst).toBe(false);
expect(page5.page.isLast).toBe(true);
expect(page5.page.totalElements).toBe(25);
expect(page5.page.totalPages).toBe(3);
// Test 6: Custom page size
const page6 = await app.users.paginate(
{ page: 0, size: 7 },
{},
{ count: true },
);
expect(page6.content.length).toBe(7);
expect(page6.page.number).toBe(0);
expect(page6.page.size).toBe(7);
expect(page6.page.offset).toBe(0);
expect(page6.page.numberOfElements).toBe(7);
expect(page6.page.isEmpty).toBe(false);
expect(page6.page.isFirst).toBe(true);
expect(page6.page.isLast).toBe(false);
expect(page6.page.totalElements).toBe(25);
expect(page6.page.totalPages).toBe(4); // Math.ceil(25 / 7) = 4
// Test 7: Page size equals total items
const page7 = await app.users.paginate(
{ page: 0, size: 25 },
{},
{ count: true },
);
expect(page7.content.length).toBe(25);
expect(page7.page.number).toBe(0);
expect(page7.page.size).toBe(25);
expect(page7.page.offset).toBe(0);
expect(page7.page.numberOfElements).toBe(25);
expect(page7.page.isEmpty).toBe(false);
expect(page7.page.isFirst).toBe(true);
expect(page7.page.isLast).toBe(true);
expect(page7.page.totalElements).toBe(25);
expect(page7.page.totalPages).toBe(1);
// Test 8: Page size greater than total items
const page8 = await app.users.paginate(
{ page: 0, size: 50 },
{},
{ count: true },
);
expect(page8.content.length).toBe(25);
expect(page8.page.number).toBe(0);
expect(page8.page.size).toBe(50);
expect(page8.page.offset).toBe(0);
expect(page8.page.numberOfElements).toBe(25);
expect(page8.page.isEmpty).toBe(false);
expect(page8.page.isFirst).toBe(true);
expect(page8.page.isLast).toBe(true);
expect(page8.page.totalElements).toBe(25);
expect(page8.page.totalPages).toBe(1); // Math.ceil(25 / 50) = 1
// Test 9: Empty table
await app.users.clear({ force: true });
const page9 = await app.users.paginate(
{ page: 0, size: 10 },
{},
{ count: true },
);
expect(page9.content.length).toBe(0);
expect(page9.page.number).toBe(0);
expect(page9.page.size).toBe(10);
expect(page9.page.offset).toBe(0);
expect(page9.page.numberOfElements).toBe(0);
expect(page9.page.isEmpty).toBe(true);
expect(page9.page.isFirst).toBe(true);
expect(page9.page.isLast).toBe(true);
expect(page9.page.totalElements).toBe(0);
expect(page9.page.totalPages).toBe(0); // Math.ceil(0 / 10) = 0
// Test 10: Single item
await app.users.create({
name: "Single User",
profile: { age: 25 },
role: "user",
});
const page10 = await app.users.paginate(
{ page: 0, size: 10 },
{},
{ count: true },
);
expect(page10.content.length).toBe(1);
expect(page10.page.number).toBe(0);
expect(page10.page.size).toBe(10);
expect(page10.page.offset).toBe(0);
expect(page10.page.numberOfElements).toBe(1);
expect(page10.page.isEmpty).toBe(false);
expect(page10.page.isFirst).toBe(true);
expect(page10.page.isLast).toBe(true);
expect(page10.page.totalElements).toBe(1);
expect(page10.page.totalPages).toBe(1); // Math.ceil(1 / 10) = 1
// Test 11: Pagination with where clause
await app.users.clear({ force: true });
for (let i = 1; i <= 20; i++) {
await app.users.create({
name: `User ${i}`,
profile: { age: 20 + i },
role: i % 2 === 0 ? "admin" : "user",
});
}
const page11 = await app.users.paginate(
{ page: 0, size: 5 },
{ where: { role: "admin" } },
{ count: true },
);
expect(page11.content.length).toBe(5);
expect(page11.page.number).toBe(0);
expect(page11.page.size).toBe(5);
expect(page11.page.offset).toBe(0);
expect(page11.page.numberOfElements).toBe(5);
expect(page11.page.isEmpty).toBe(false);
expect(page11.page.isFirst).toBe(true);
expect(page11.page.isLast).toBe(false);
expect(page11.page.totalElements).toBe(10); // Only admin users
expect(page11.page.totalPages).toBe(2); // Math.ceil(10 / 5) = 2
// Test 12: Last page of filtered results
const page12 = await app.users.p