Skip to content

Commit 4b39d7e

Browse files
authored
Merge pull request #2 from mikro-orm/chore/add-ci-and-tests
chore: add CI workflow and vitest tests
2 parents f44c758 + 656f70e commit 4b39d7e

9 files changed

Lines changed: 2221 additions & 1204 deletions

File tree

.github/workflows/tests.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [master, main, renovate/**]
6+
pull_request:
7+
branches: [master, main]
8+
9+
jobs:
10+
test:
11+
name: Tests
12+
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
node-version: [22, 24]
18+
steps:
19+
- name: Checkout Source code
20+
uses: actions/checkout@v6
21+
22+
- name: Use Node.js ${{ matrix.node-version }}
23+
uses: actions/setup-node@v6
24+
with:
25+
node-version: ${{ matrix.node-version }}
26+
27+
- name: Install
28+
run: npm ci
29+
30+
- name: Build
31+
run: npm run build
32+
33+
- name: Test
34+
run: npm test

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ yarn-error.log*
4242
next-env.d.ts
4343

4444
sqlite.db
45+
.snapshot-sqlite.db.json

__tests__/actions.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { MikroORM, RequestContext } from '@mikro-orm/sqlite';
2+
import { wrap } from '@mikro-orm/core';
3+
import { User, UserSchema, Social } from '@/lib/user.entity';
4+
import { ArticleSchema } from '@/lib/article.entity';
5+
import { ArticleListingSchema } from '@/lib/article-listing.entity';
6+
import { TagSchema } from '@/lib/tag.entity';
7+
import { CommentSchema } from '@/lib/comment.entity';
8+
9+
let orm: MikroORM;
10+
11+
beforeAll(async () => {
12+
orm = new MikroORM({
13+
dbName: ':memory:',
14+
entities: [UserSchema, ArticleSchema, ArticleListingSchema, TagSchema, Social, CommentSchema],
15+
});
16+
17+
await orm.schema.drop();
18+
await orm.schema.create();
19+
});
20+
21+
afterAll(async () => {
22+
await orm.close(true);
23+
});
24+
25+
describe('server action logic', () => {
26+
test('create user and serialize', async () => {
27+
// replicate what the createUser action does
28+
await RequestContext.create(orm.em, async () => {
29+
const em = orm.em;
30+
const user = em.create(User, {
31+
fullName: 'Action User',
32+
email: 'action@example.com',
33+
password: 'secret',
34+
});
35+
await em.flush();
36+
37+
const serialized = wrap(user).toObject();
38+
expect(serialized).toHaveProperty('fullName', 'Action User');
39+
expect(serialized).toHaveProperty('bio', '');
40+
// email and password are hidden, should not appear in toObject
41+
expect(serialized).not.toHaveProperty('email');
42+
expect(serialized).not.toHaveProperty('password');
43+
});
44+
});
45+
46+
test('truncate users', async () => {
47+
// first ensure we have a user
48+
const em = orm.em.fork();
49+
const count = await em.count(User);
50+
expect(count).toBeGreaterThan(0);
51+
52+
// replicate what resetUsers does
53+
await em.qb(User).truncate().execute();
54+
55+
const countAfter = await orm.em.fork().count(User);
56+
expect(countAfter).toBe(0);
57+
});
58+
});

__tests__/db.spec.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { MikroORM } from '@mikro-orm/sqlite';
2+
import { User, UserSchema, Social } from '@/lib/user.entity';
3+
import { Article, ArticleSchema } from '@/lib/article.entity';
4+
import { ArticleListingSchema } from '@/lib/article-listing.entity';
5+
import { TagSchema } from '@/lib/tag.entity';
6+
import { CommentSchema } from '@/lib/comment.entity';
7+
8+
let orm: MikroORM;
9+
10+
beforeAll(async () => {
11+
orm = new MikroORM({
12+
dbName: ':memory:',
13+
entities: [UserSchema, ArticleSchema, ArticleListingSchema, TagSchema, Social, CommentSchema],
14+
});
15+
16+
await orm.schema.drop();
17+
await orm.schema.create();
18+
});
19+
20+
afterAll(async () => {
21+
await orm.close(true);
22+
});
23+
24+
describe('User entity', () => {
25+
test('create a user', async () => {
26+
const em = orm.em.fork();
27+
const user = em.create(User, {
28+
fullName: 'John Doe',
29+
email: 'john@example.com',
30+
password: 'secret123',
31+
});
32+
await em.flush();
33+
34+
expect(user.id).toBeDefined();
35+
expect(user.fullName).toBe('John Doe');
36+
expect(user.bio).toBe('');
37+
expect(user.createdAt).toBeInstanceOf(Date);
38+
expect(user.updatedAt).toBeInstanceOf(Date);
39+
});
40+
41+
test('password is hashed on create', async () => {
42+
const em = orm.em.fork();
43+
const user = await em.findOneOrFail(User, { email: 'john@example.com' }, { populate: ['password'] });
44+
const password = await user.password.load();
45+
// argon2 hashes start with $argon2
46+
expect(password).toMatch(/^\$argon2/);
47+
});
48+
49+
test('verify password', async () => {
50+
const em = orm.em.fork();
51+
const user = await em.findOneOrFail(User, { email: 'john@example.com' }, { populate: ['password'] });
52+
expect(await user.verifyPassword('secret123')).toBe(true);
53+
expect(await user.verifyPassword('wrongpassword')).toBe(false);
54+
});
55+
56+
test('unique email constraint', async () => {
57+
const em = orm.em.fork();
58+
em.create(User, {
59+
fullName: 'Jane Doe',
60+
email: 'john@example.com', // duplicate
61+
password: 'password',
62+
});
63+
await expect(em.flush()).rejects.toThrow();
64+
});
65+
66+
test('user repository exists method', async () => {
67+
const em = orm.em.fork();
68+
const repo = em.getRepository(User);
69+
expect(await repo.exists('john@example.com')).toBe(true);
70+
expect(await repo.exists('nonexistent@example.com')).toBe(false);
71+
});
72+
73+
test('user repository login method', async () => {
74+
const em = orm.em.fork();
75+
const repo = em.getRepository(User);
76+
const user = await repo.login('john@example.com', 'secret123');
77+
expect(user.fullName).toBe('John Doe');
78+
});
79+
80+
test('user repository login with wrong password', async () => {
81+
const em = orm.em.fork();
82+
const repo = em.getRepository(User);
83+
await expect(repo.login('john@example.com', 'wrong')).rejects.toThrow('Invalid combination of email and password');
84+
});
85+
});
86+
87+
describe('Article entity', () => {
88+
test('create an article with author', async () => {
89+
const em = orm.em.fork();
90+
const author = await em.findOneOrFail(User, { email: 'john@example.com' });
91+
const article = em.create(Article, {
92+
author,
93+
title: 'My First Article',
94+
description: 'A test article',
95+
text: 'Hello world content',
96+
});
97+
await em.flush();
98+
99+
expect(article.id).toBeDefined();
100+
expect(article.slug).toBe('my-first-article');
101+
expect(article.title).toBe('My First Article');
102+
expect(article.description).toBe('A test article');
103+
expect(article.author.id).toBe(author.id);
104+
});
105+
106+
test('article slug is generated from title', async () => {
107+
const em = orm.em.fork();
108+
const author = await em.findOneOrFail(User, { email: 'john@example.com' });
109+
const article = em.create(Article, {
110+
author,
111+
title: 'Hello World!!! Test & Check',
112+
description: 'desc',
113+
text: 'text',
114+
});
115+
await em.flush();
116+
117+
expect(article.slug).toBe('hello-world-test-check');
118+
});
119+
120+
test('article text is lazy loaded', async () => {
121+
const em = orm.em.fork();
122+
const article = await em.findOneOrFail(Article, { slug: 'my-first-article' });
123+
// text is a lazy Ref, not loaded by default
124+
expect(article.text.isInitialized()).toBe(false);
125+
126+
const text = await article.text.load();
127+
expect(text).toBe('Hello world content');
128+
});
129+
});
130+
131+
describe('Tag entity', () => {
132+
test('create tags and assign to article', async () => {
133+
const em = orm.em.fork();
134+
const article = await em.findOneOrFail(Article, { slug: 'my-first-article' }, { populate: ['tags'] });
135+
136+
const tag1 = em.create(TagSchema, { name: 'typescript' });
137+
const tag2 = em.create(TagSchema, { name: 'orm' });
138+
article.tags.add(tag1, tag2);
139+
await em.flush();
140+
141+
expect(tag1.id).toBeDefined();
142+
expect(tag2.id).toBeDefined();
143+
expect(article.tags).toHaveLength(2);
144+
});
145+
146+
test('tags are persisted via many-to-many', async () => {
147+
const em = orm.em.fork();
148+
const article = await em.findOneOrFail(Article, { slug: 'my-first-article' }, { populate: ['tags'] });
149+
expect(article.tags).toHaveLength(2);
150+
const tagNames = article.tags.getItems().map(t => t.name);
151+
expect(tagNames).toContain('typescript');
152+
expect(tagNames).toContain('orm');
153+
});
154+
});
155+
156+
describe('Comment entity', () => {
157+
test('create a comment on an article', async () => {
158+
const em = orm.em.fork();
159+
const author = await em.findOneOrFail(User, { email: 'john@example.com' });
160+
const article = await em.findOneOrFail(Article, { slug: 'my-first-article' });
161+
162+
const comment = em.create(CommentSchema, {
163+
text: 'Great article!',
164+
article,
165+
author,
166+
});
167+
await em.flush();
168+
169+
expect(comment.id).toBeDefined();
170+
expect(comment.text).toBe('Great article!');
171+
});
172+
173+
test('comments are eagerly loaded with article', async () => {
174+
const em = orm.em.fork();
175+
const article = await em.findOneOrFail(Article, { slug: 'my-first-article' });
176+
// comments are defined as eager in ArticleSchema
177+
expect(article.comments).toHaveLength(1);
178+
expect(article.comments[0].text).toBe('Great article!');
179+
});
180+
181+
test('orphan removal deletes comments when removed from collection', async () => {
182+
const em = orm.em.fork();
183+
const article = await em.findOneOrFail(Article, { slug: 'my-first-article' });
184+
expect(article.comments).toHaveLength(1);
185+
186+
article.comments.removeAll();
187+
await em.flush();
188+
189+
const comments = await em.fork().find(CommentSchema, { article });
190+
expect(comments).toHaveLength(0);
191+
});
192+
});
193+
194+
describe('ArticleListing virtual entity', () => {
195+
test('list articles via repository', async () => {
196+
const em = orm.em.fork();
197+
const repo = em.getRepository(Article);
198+
const { items, total } = await repo.listArticles({});
199+
200+
expect(total).toBeGreaterThanOrEqual(1);
201+
expect(items[0]).toHaveProperty('slug');
202+
expect(items[0]).toHaveProperty('title');
203+
expect(items[0]).toHaveProperty('authorName');
204+
});
205+
});

lib/article.entity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const ArticleSchema = defineEntity({
5151
slug: p.string().unique(),
5252
title: p.string().index(),
5353
description: p.string().length(1000),
54-
text: p.text().lazy(),
54+
text: p.text().lazy().ref(),
5555
tags: () => p.manyToMany(TagSchema),
5656
author: () => p.manyToOne(User).ref(),
5757
comments: () => p.oneToMany(CommentSchema).mappedBy('article').eager().orphanRemoval(),

lib/user.entity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export const UserSchema = defineEntity({
6969
properties: {
7070
fullName: p.string(),
7171
email: p.string().unique().hidden(),
72-
password: p.string().hidden().lazy(),
72+
password: p.string().hidden().lazy().ref(),
7373
bio: p.text().default(''),
7474
articles: () => p.oneToMany(Article).mappedBy('author').hidden(),
7575
token: p.string().nullable().persist(false),

0 commit comments

Comments
 (0)