@koalarx/nest 4.0.6 → 4.0.8

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.
Files changed (48) hide show
  1. package/cli/constants/cli-project-checklist.js +5 -0
  2. package/cli/constants/core-packages.js +4 -6
  3. package/cli/utils/install-module.js +10 -7
  4. package/cli/utils/parse-new-args.js +0 -1
  5. package/cli/utils/patch-env.js +34 -0
  6. package/cli/utils/patch-main.js +3 -4
  7. package/cli/utils/remove-sample-parts.js +1 -2
  8. package/koala-nest/.env.example +12 -0
  9. package/koala-nest/src/application/common/pagination.request.ts +0 -5
  10. package/koala-nest/src/application/mapping/person.mapper.ts +0 -9
  11. package/koala-nest/src/application/person/jobs/cron/delete-inactive.job.ts +34 -17
  12. package/koala-nest/src/application/person/read-many/read-many-person.response.ts +2 -13
  13. package/koala-nest/src/core/background-services/cron-service/cron-job.handler.base.ts +36 -15
  14. package/koala-nest/src/core/background-services/event-service/event-queue.ts +2 -3
  15. package/koala-nest/src/core/common/list-response.base.ts +10 -0
  16. package/koala-nest/src/core/env.ts +5 -0
  17. package/koala-nest/src/core/http/rate-limit.middleware.ts +36 -0
  18. package/koala-nest/src/core/tools/mapping/auto-map.ts +27 -3
  19. package/koala-nest/src/core/tools/mapping/auto-mapper.ts +25 -11
  20. package/koala-nest/src/core/tools/mapping/mapping-store.ts +13 -15
  21. package/koala-nest/src/core/utils/cron-expression-to-boolean.ts +32 -17
  22. package/koala-nest/src/core/utils/hash-password.ts +7 -2
  23. package/koala-nest/src/core/utils/initialize-undefined-array-props.ts +20 -0
  24. package/koala-nest/src/core/utils/resolve-cors-origins.ts +24 -0
  25. package/koala-nest/src/domain/dtos/pagination.dto.ts +87 -2
  26. package/koala-nest/src/domain/dtos/person-query.dto.ts +8 -0
  27. package/koala-nest/src/domain/entities/person/person.ts +0 -2
  28. package/koala-nest/src/host/bootstrap/apply-http-middleware.ts +22 -0
  29. package/koala-nest/src/host/main.ts +14 -14
  30. package/koala-nest/src/infra/common/redis-cache.service.ts +15 -4
  31. package/koala-nest/src/infra/repositories/person.repository.ts +3 -3
  32. package/koala-nest/src/infra/repositories/repository.base.ts +70 -1
  33. package/koala-nest/src/test/application/delete-inactive.job.spec.ts +23 -10
  34. package/koala-nest/src/test/application/update-person.handler.spec.ts +42 -0
  35. package/koala-nest/src/test/core/cron-expression-to-boolean.spec.ts +20 -1
  36. package/koala-nest/src/test/core/env.spec.ts +19 -0
  37. package/koala-nest/src/test/core/http/rate-limit.middleware.spec.ts +62 -0
  38. package/koala-nest/src/test/core/initialize-undefined-array-props.spec.ts +63 -0
  39. package/koala-nest/src/test/core/mapping.spec.ts +15 -0
  40. package/koala-nest/src/test/core/pagination-typeorm.spec.ts +74 -0
  41. package/koala-nest/src/test/core/resolve-cors-origins.spec.ts +22 -0
  42. package/koala-nest/src/test/core/sync-improvements.spec.ts +39 -0
  43. package/koala-nest/src/test/host/controllers/person/lazy-loading.e2e.spec.ts +20 -60
  44. package/koala-nest/src/test/infra/redis-cache.service.spec.ts +8 -0
  45. package/koala-nest/src/test/utils/configure-test-app.ts +2 -8
  46. package/koala-nest/src/test/utils/in-memory-base.repository.ts +80 -0
  47. package/package.json +1 -1
  48. package/koala-nest/src/core/utils/icomparable.ts +0 -1
@@ -30,8 +30,15 @@ export class MappingStore {
30
30
  entity.prototype,
31
31
  propName,
32
32
  );
33
- const isArray = propType === Array;
34
- const type = isArray ? (compositionType ?? propType) : propType;
33
+ const explicitComposition = Reflect.getMetadata(
34
+ 'composition:type',
35
+ entity.prototype,
36
+ propName,
37
+ );
38
+ const isArray = propType === Array || explicitComposition != null;
39
+ const type = isArray
40
+ ? (compositionType ?? explicitComposition ?? propType)
41
+ : propType;
35
42
 
36
43
  const props = this._entities.get(entity) || [];
37
44
 
@@ -64,6 +71,10 @@ export class MappingStore {
64
71
  return props;
65
72
  }
66
73
 
74
+ static getAllProps(entity: Type<any>) {
75
+ return this.getProps(entity);
76
+ }
77
+
67
78
  static getPropType(entity: Type<any>, propName: string) {
68
79
  let current: Type<any> | null = entity;
69
80
 
@@ -113,17 +124,4 @@ export class MappingStore {
113
124
  static getMapping(source: Type<any>, target: Type<any>) {
114
125
  return this._mappings.get(source)?.get(target) ?? null;
115
126
  }
116
-
117
- /** Compatibilidade com chave legada usada em testes. */
118
- static getMappingByName(mapName: string) {
119
- for (const targetMappings of this._mappings.values()) {
120
- for (const mapping of targetMappings.values()) {
121
- if (`${mapping.source.name}To${mapping.target.name}` === mapName) {
122
- return mapping;
123
- }
124
- }
125
- }
126
-
127
- return null;
128
- }
129
127
  }
@@ -1,5 +1,36 @@
1
1
  import { CronExpressionParser } from 'cron-parser';
2
2
 
3
+ /**
4
+ * Retorna uma chave única do tick cron atual, ou null se o instante não coincide.
5
+ */
6
+ export function getCronExecutionKey(
7
+ cronExpression: string,
8
+ now: Date = new Date(),
9
+ ): string | null {
10
+ try {
11
+ const interval = CronExpressionParser.parse(cronExpression, {
12
+ currentDate: now,
13
+ });
14
+ const prev = interval.prev();
15
+
16
+ const matches =
17
+ prev.getFullYear() === now.getFullYear() &&
18
+ prev.getMonth() === now.getMonth() &&
19
+ prev.getDate() === now.getDate() &&
20
+ prev.getHours() === now.getHours() &&
21
+ prev.getMinutes() === now.getMinutes() &&
22
+ prev.getSeconds() === now.getSeconds();
23
+
24
+ if (!matches) {
25
+ return null;
26
+ }
27
+
28
+ return prev.toISOString();
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
3
34
  /**
4
35
  * Verifica se a expressão cron corresponde ao momento atual.
5
36
  *
@@ -45,21 +76,5 @@ export function cronExpressionToBoolean(
45
76
  cronExpression: string,
46
77
  now: Date = new Date(),
47
78
  ): boolean {
48
- try {
49
- const interval = CronExpressionParser.parse(cronExpression, {
50
- currentDate: now,
51
- });
52
- const prev = interval.prev();
53
-
54
- return (
55
- prev.getFullYear() === now.getFullYear() &&
56
- prev.getMonth() === now.getMonth() &&
57
- prev.getDate() === now.getDate() &&
58
- prev.getHours() === now.getHours() &&
59
- prev.getMinutes() === now.getMinutes() &&
60
- prev.getSeconds() === now.getSeconds()
61
- );
62
- } catch {
63
- return false;
64
- }
79
+ return getCronExecutionKey(cronExpression, now) !== null;
65
80
  }
@@ -1,5 +1,10 @@
1
1
  import { hash } from 'bcrypt';
2
2
 
3
- export function hashPassword(password: string): Promise<string> {
4
- return hash(password, 6);
3
+ const DEFAULT_BCRYPT_ROUNDS = 10;
4
+
5
+ export function hashPassword(
6
+ password: string,
7
+ rounds = Number(process.env.BCRYPT_ROUNDS) || DEFAULT_BCRYPT_ROUNDS,
8
+ ): Promise<string> {
9
+ return hash(password, rounds);
5
10
  }
@@ -0,0 +1,20 @@
1
+ import type { Type } from '@nestjs/common';
2
+ import { MappingStore } from '@/core/tools/mapping/mapping-store';
3
+
4
+ export function initializeUndefinedArrayProps(
5
+ target: Record<string, unknown>,
6
+ entity: Type<unknown>,
7
+ onlyProps?: string[],
8
+ ): void {
9
+ MappingStore.getAllProps(entity).forEach((prop) => {
10
+ if (onlyProps && !onlyProps.includes(prop.name)) {
11
+ return;
12
+ }
13
+
14
+ if (target[prop.name] !== undefined || !prop.isArray) {
15
+ return;
16
+ }
17
+
18
+ target[prop.name] = [];
19
+ });
20
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CORS aberto por padrão (`true` = reflete a origin da requisição).
3
+ * Defina `CORS_ORIGINS` apenas quando quiser restringir a origens específicas.
4
+ */
5
+ export function resolveCorsOrigin(
6
+ origins: string | undefined,
7
+ ): boolean | string | string[] {
8
+ const normalized = origins?.trim();
9
+
10
+ if (!normalized) {
11
+ return true;
12
+ }
13
+
14
+ const list = normalized
15
+ .split(',')
16
+ .map((origin) => origin.trim())
17
+ .filter(Boolean);
18
+
19
+ if (list.length === 1) {
20
+ return list[0];
21
+ }
22
+
23
+ return list;
24
+ }
@@ -1,6 +1,61 @@
1
1
  import { QUERY_FILTER_PARAMS } from '@/core/constants/query-params';
2
2
  import { AutoMap } from '@/core/tools/mapping';
3
3
  import type { QueryDirectionType } from '@/core/types';
4
+ import type {
5
+ FindOptionsOrder,
6
+ ObjectLiteral,
7
+ SelectQueryBuilder,
8
+ } from 'typeorm';
9
+
10
+ type OrderDirection = 'ASC' | 'DESC';
11
+
12
+ function normalizeDirection(
13
+ direction?: QueryDirectionType | string,
14
+ ): OrderDirection {
15
+ return String(direction ?? 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
16
+ }
17
+
18
+ function flattenOrderBy(
19
+ order: Record<string, unknown>,
20
+ prefix = '',
21
+ ): Array<{ path: string; direction: OrderDirection }> {
22
+ const entries: Array<{ path: string; direction: OrderDirection }> = [];
23
+
24
+ for (const [key, value] of Object.entries(order)) {
25
+ const path = prefix ? `${prefix}.${key}` : key;
26
+
27
+ if (typeof value === 'string') {
28
+ entries.push({ path, direction: normalizeDirection(value) });
29
+ continue;
30
+ }
31
+
32
+ if (value && typeof value === 'object') {
33
+ entries.push(...flattenOrderBy(value as Record<string, unknown>, path));
34
+ }
35
+ }
36
+
37
+ return entries;
38
+ }
39
+
40
+ function normalizeFindOptionsOrder(
41
+ order: Record<string, unknown>,
42
+ ): FindOptionsOrder<ObjectLiteral> {
43
+ const normalized: Record<string, unknown> = {};
44
+
45
+ for (const [key, value] of Object.entries(order)) {
46
+ if (typeof value === 'string') {
47
+ normalized[key] = normalizeDirection(value);
48
+ } else if (value && typeof value === 'object') {
49
+ normalized[key] = normalizeFindOptionsOrder(
50
+ value as Record<string, unknown>,
51
+ );
52
+ } else {
53
+ normalized[key] = value;
54
+ }
55
+ }
56
+
57
+ return normalized as FindOptionsOrder<ObjectLiteral>;
58
+ }
4
59
 
5
60
  export class PaginationDto {
6
61
  @AutoMap()
@@ -19,17 +74,47 @@ export class PaginationDto {
19
74
  return (this.limit ?? 0) * (this.page ?? QUERY_FILTER_PARAMS.page);
20
75
  }
21
76
 
22
- generateOrderBy() {
77
+ generateOrderBy(): Record<string, unknown> | undefined {
23
78
  if (this.orderBy) {
24
79
  const orderByField = this.orderBy.split('.');
25
80
  return orderByField.reduceRight(
26
81
  (acc, item, index) => ({
27
82
  [item]: index === orderByField.length - 1 ? this.direction : acc,
28
83
  }),
29
- {},
84
+ {} as Record<string, unknown>,
30
85
  );
31
86
  }
32
87
 
33
88
  return undefined;
34
89
  }
90
+
91
+ toFindOptionsOrder(): FindOptionsOrder<ObjectLiteral> | undefined {
92
+ const order = this.generateOrderBy();
93
+
94
+ if (!order) {
95
+ return undefined;
96
+ }
97
+
98
+ return normalizeFindOptionsOrder(order);
99
+ }
100
+
101
+ applyQueryBuilderPagination<T extends ObjectLiteral>(
102
+ qb: SelectQueryBuilder<T>,
103
+ alias: string,
104
+ ): SelectQueryBuilder<T> {
105
+ const order = this.generateOrderBy();
106
+
107
+ if (order) {
108
+ for (const { path, direction } of flattenOrderBy(order)) {
109
+ const sortPath = path.includes('.') ? path : `${alias}.${path}`;
110
+ qb.addOrderBy(sortPath, direction);
111
+ }
112
+ }
113
+
114
+ return qb.skip(this.skip()).take(this.limit);
115
+ }
116
+
117
+ static from<T extends PaginationDto>(this: new () => T, props: Partial<T>): T {
118
+ return Object.assign(new this(), props);
119
+ }
35
120
  }
@@ -7,4 +7,12 @@ export class PersonQueryDto extends PaginationDto {
7
7
 
8
8
  @AutoMap()
9
9
  active?: boolean;
10
+
11
+ override generateOrderBy() {
12
+ if (this.orderBy) {
13
+ return super.generateOrderBy();
14
+ }
15
+
16
+ return { id: 'asc' };
17
+ }
10
18
  }
@@ -27,7 +27,6 @@ export class Person extends EntityBase<Person> {
27
27
 
28
28
  @OneToOne(() => PersonAddress, {
29
29
  cascade: true,
30
- eager: true,
31
30
  onDelete: 'CASCADE',
32
31
  })
33
32
  @JoinColumn()
@@ -36,7 +35,6 @@ export class Person extends EntityBase<Person> {
36
35
 
37
36
  @OneToMany(() => PersonContact, (contact) => contact.person, {
38
37
  cascade: true,
39
- eager: true,
40
38
  onDelete: 'CASCADE',
41
39
  })
42
40
  @AutoMap({ type: () => PersonContact })
@@ -0,0 +1,22 @@
1
+ import { createRateLimitMiddleware } from '@/core/http/rate-limit.middleware';
2
+ import { resolveCorsOrigin } from '@/core/utils/resolve-cors-origins';
3
+ import { EnvService } from '@/infra/common/env.service';
4
+ import type { INestApplication } from '@nestjs/common';
5
+ import cookieParser from 'cookie-parser';
6
+
7
+ export function applyHttpMiddleware(app: INestApplication) {
8
+ const env = app.get(EnvService);
9
+
10
+ app.use(cookieParser());
11
+ app.use(
12
+ createRateLimitMiddleware({
13
+ windowMs: env.get('RATE_LIMIT_WINDOW_MS'),
14
+ maxRequests: env.get('RATE_LIMIT_MAX'),
15
+ }),
16
+ );
17
+ app.enableCors({
18
+ credentials: true,
19
+ origin: resolveCorsOrigin(env.get('CORS_ORIGINS')),
20
+ optionsSuccessStatus: 200,
21
+ });
22
+ }
@@ -1,7 +1,8 @@
1
1
  import 'dotenv/config';
2
2
 
3
+ import { applyHttpMiddleware } from '@/host/bootstrap/apply-http-middleware';
4
+ import { resolveApiHost } from '@/core/utils/resolve-api-host';
3
5
  import { NestFactory } from '@nestjs/core';
4
- import cookieParser from 'cookie-parser';
5
6
  import { AppModule } from './app.module';
6
7
  import { defineDocumentation } from './open-api/define-documentation';
7
8
  import { ErrorsFilter } from './filters/errors.filter';
@@ -13,13 +14,7 @@ import { ILoggingService } from '@/domain/common/ilogging.service';
13
14
  async function bootstrap() {
14
15
  const app = await NestFactory.create(AppModule);
15
16
 
16
- app.use(cookieParser());
17
-
18
- app.enableCors({
19
- credentials: true,
20
- origin: true,
21
- optionsSuccessStatus: 200,
22
- });
17
+ applyHttpMiddleware(app);
23
18
 
24
19
  await defineDocumentation(app);
25
20
 
@@ -32,12 +27,17 @@ async function bootstrap() {
32
27
  await app.resolve(ProfilesGuard),
33
28
  );
34
29
 
35
- await app.listen(process.env.PORT || 3000);
30
+ const port = Number(process.env.PORT) || 3000;
31
+ const bindHost = process.env.HOST ?? '0.0.0.0';
32
+ const publicHost = resolveApiHost(process.env.API_HOST, port);
36
33
 
37
- console.log(`Server is running on port ${process.env.PORT || 3000}`);
38
- console.log(
39
- `Documentation is available at http://localhost:${process.env.PORT || 3000}/doc`,
40
- );
34
+ await app.listen(port, bindHost);
35
+
36
+ console.log(`Server is running on ${publicHost}`);
37
+ console.log(`Documentation is available at ${publicHost}/doc`);
41
38
  }
42
39
 
43
- bootstrap();
40
+ bootstrap().catch((error) => {
41
+ console.error(error);
42
+ process.exit(1);
43
+ });
@@ -59,11 +59,22 @@ export class RedisCacheService implements ICacheService {
59
59
 
60
60
  async invalidateByPrefix(prefix: string): Promise<void> {
61
61
  const pattern = `${this.buildKey(prefix)}*`;
62
- const keys = await this.client.keys(pattern);
62
+ let cursor = '0';
63
63
 
64
- if (keys.length > 0) {
65
- await this.client.del(...keys);
66
- }
64
+ do {
65
+ const [nextCursor, keys] = await this.client.scan(
66
+ cursor,
67
+ 'MATCH',
68
+ pattern,
69
+ 'COUNT',
70
+ 100,
71
+ );
72
+ cursor = nextCursor;
73
+
74
+ if (keys.length > 0) {
75
+ await this.client.del(...keys);
76
+ }
77
+ } while (cursor !== '0');
67
78
  }
68
79
 
69
80
  onModuleDestroy() {
@@ -30,18 +30,18 @@ export class PersonRepository
30
30
  return this.repository
31
31
  .findAndCount({
32
32
  where,
33
- order: query.generateOrderBy(),
33
+ order: query.toFindOptionsOrder(),
34
34
  skip: query.skip(),
35
35
  take: query.limit,
36
36
  })
37
37
  .then(([items, count]) => ({
38
- items,
38
+ items: this.normalizeEntities(items),
39
39
  count,
40
40
  }));
41
41
  }
42
42
 
43
43
  findById(id: number): Promise<Person | null> {
44
- return this.repository.findOne({
44
+ return this.findOneNormalized({
45
45
  where: { id },
46
46
  relations: { address: true, contacts: true },
47
47
  });
@@ -1,4 +1,27 @@
1
- import { DataSource, EntityTarget, ObjectLiteral, Repository } from 'typeorm';
1
+ import { initializeUndefinedArrayProps } from '@/core/utils/initialize-undefined-array-props';
2
+ import type { Type } from '@nestjs/common';
3
+ import {
4
+ DataSource,
5
+ EntityTarget,
6
+ FindManyOptions,
7
+ FindOneOptions,
8
+ ObjectLiteral,
9
+ Repository,
10
+ } from 'typeorm';
11
+
12
+ function getLoadedRelationKeys(
13
+ relations?: FindOneOptions<ObjectLiteral>['relations'],
14
+ ): string[] {
15
+ if (!relations) {
16
+ return [];
17
+ }
18
+
19
+ if (Array.isArray(relations)) {
20
+ return relations.map(String);
21
+ }
22
+
23
+ return Object.keys(relations);
24
+ }
2
25
 
3
26
  export class RepositoryBase<T extends ObjectLiteral> {
4
27
  protected readonly repository: Repository<T>;
@@ -10,6 +33,52 @@ export class RepositoryBase<T extends ObjectLiteral> {
10
33
  this.repository = this.dataSource.getRepository<T>(entity);
11
34
  }
12
35
 
36
+ protected normalizeEntity<E extends ObjectLiteral>(
37
+ entity: E,
38
+ loadedRelationKeys: string[] = [],
39
+ ): E {
40
+ if (loadedRelationKeys.length > 0) {
41
+ initializeUndefinedArrayProps(
42
+ entity as Record<string, unknown>,
43
+ this.getEntityType(),
44
+ loadedRelationKeys,
45
+ );
46
+ }
47
+
48
+ return entity;
49
+ }
50
+
51
+ protected normalizeEntities<E extends ObjectLiteral>(
52
+ entities: E[],
53
+ loadedRelationKeys: string[] = [],
54
+ ): E[] {
55
+ return entities.map((entity) =>
56
+ this.normalizeEntity(entity, loadedRelationKeys),
57
+ );
58
+ }
59
+
60
+ protected getEntityType(): Type<unknown> {
61
+ return this.entity as Type<unknown>;
62
+ }
63
+
64
+ protected findOneNormalized(options: FindOneOptions<T>) {
65
+ const loadedRelationKeys = getLoadedRelationKeys(options.relations);
66
+
67
+ return this.repository
68
+ .findOne(options)
69
+ .then((entity) =>
70
+ entity ? this.normalizeEntity(entity, loadedRelationKeys) : null,
71
+ );
72
+ }
73
+
74
+ protected findNormalized(options: FindManyOptions<T>) {
75
+ const loadedRelationKeys = getLoadedRelationKeys(options.relations);
76
+
77
+ return this.repository
78
+ .find(options)
79
+ .then((entities) => this.normalizeEntities(entities, loadedRelationKeys));
80
+ }
81
+
13
82
  save(entity: T) {
14
83
  return this.repository.save(entity);
15
84
  }
@@ -1,19 +1,32 @@
1
+ import { describe, expect, it } from 'bun:test';
1
2
  import { DeleteInactiveJob } from '@/application/person/jobs/cron/delete-inactive.job';
2
3
  import { DeletePersonHandler } from '@/application/person/delete/delete-person.handler';
3
- import { ReadManyPersonHandler } from '@/application/person/read-many/read-many-person.handler';
4
+ import { Person } from '@/domain/entities/person/person';
5
+ import type { IPersonRepository } from '@/domain/repositories/iperson.repository';
4
6
  import { FakeLoggingService } from '@/test/services/fake-logging.service';
5
7
  import { FakeRedLockService } from '@/test/services/fake-red-lock.service';
6
8
 
7
9
  describe('DeleteInactiveJob', () => {
8
- it('remove pessoas inativas retornadas pela listagem', async () => {
10
+ it('remove todas as páginas de pessoas inativas', async () => {
9
11
  const deletedIds: number[] = [];
12
+ const inactivePeople = Array.from({ length: 35 }, (_, index) => {
13
+ const person = new Person();
14
+ person.id = index + 1;
15
+ person.name = `Inactive ${index + 1}`;
16
+ person.active = false;
17
+ return person;
18
+ });
10
19
 
11
- const readManyPerson = {
12
- handle: async () => ({
13
- items: [{ id: 1, name: 'Inactive', active: false }],
14
- count: 1,
15
- }),
16
- } as unknown as ReadManyPersonHandler;
20
+ const repository = {
21
+ findMany: async (query: { page?: number; limit?: number }) => {
22
+ const currentPage = query.page ?? 0;
23
+ const limit = query.limit ?? 30;
24
+ const start = currentPage * limit;
25
+ const items = inactivePeople.slice(start, start + limit);
26
+
27
+ return { items, count: inactivePeople.length };
28
+ },
29
+ } as unknown as IPersonRepository;
17
30
 
18
31
  const deletePerson = {
19
32
  handle: async (id: number) => {
@@ -24,12 +37,12 @@ describe('DeleteInactiveJob', () => {
24
37
  const job = new DeleteInactiveJob(
25
38
  new FakeRedLockService(),
26
39
  new FakeLoggingService(),
27
- readManyPerson,
40
+ repository,
28
41
  deletePerson,
29
42
  );
30
43
 
31
44
  await job['run']();
32
45
 
33
- expect(deletedIds).toEqual([1]);
46
+ expect(deletedIds).toHaveLength(35);
34
47
  });
35
48
  });
@@ -58,6 +58,48 @@ describe('UpdatePersonHandler', () => {
58
58
  expect(cache.invalidateByPrefixCalls).toEqual([PERSON_LIST_CACHE_PREFIX]);
59
59
  });
60
60
 
61
+ it('remove contatos ausentes do payload (órfãos via TypeORM)', async () => {
62
+ const cache = new CacheStub();
63
+ const keptContact = PersonContact.from({
64
+ id: 10,
65
+ contact: 'keep@example.com',
66
+ person: undefined as unknown as Person,
67
+ });
68
+ const removedContact = PersonContact.from({
69
+ id: 11,
70
+ contact: 'remove@example.com',
71
+ person: undefined as unknown as Person,
72
+ });
73
+
74
+ const person = Person.from({
75
+ id: 1,
76
+ name: 'Jane',
77
+ active: true,
78
+ address: PersonAddress.from({ id: 5, address: 'Street' }),
79
+ contacts: [keptContact, removedContact],
80
+ });
81
+ keptContact.person = person;
82
+ removedContact.person = person;
83
+
84
+ const repository = {
85
+ findById: async () => person,
86
+ save: async (entity: Person) => entity,
87
+ } as unknown as IPersonRepository;
88
+
89
+ const handler = new UpdatePersonHandler(repository, cache);
90
+
91
+ await handler.handle({
92
+ id: 1,
93
+ name: 'Jane',
94
+ address: { id: 5, address: 'Street' },
95
+ contacts: [{ id: 10, contact: 'keep@example.com' }],
96
+ });
97
+
98
+ expect(person.contacts).toHaveLength(1);
99
+ expect(person.contacts[0].id).toBe(10);
100
+ expect(person.contacts[0].contact).toBe('keep@example.com');
101
+ });
102
+
61
103
  it('lança NotFoundException quando a pessoa não existe', async () => {
62
104
  const repository = {
63
105
  findById: async () => null,
@@ -1,8 +1,27 @@
1
1
  /// <reference types="bun-types/test-globals" />
2
2
 
3
- import { cronExpressionToBoolean } from '@/core/utils/cron-expression-to-boolean';
3
+ import {
4
+ cronExpressionToBoolean,
5
+ getCronExecutionKey,
6
+ } from '@/core/utils/cron-expression-to-boolean';
4
7
  import { describe, expect, it } from 'bun:test';
5
8
 
9
+ describe('getCronExecutionKey', () => {
10
+ it('retorna ISO do tick quando o instante coincide com a expressão', () => {
11
+ const at = new Date(2024, 5, 12, 12, 0, 0, 1);
12
+
13
+ expect(getCronExecutionKey('0 0 12 * * *', at)).toBe(
14
+ new Date(2024, 5, 12, 12, 0, 0).toISOString(),
15
+ );
16
+ });
17
+
18
+ it('retorna null quando o instante não coincide', () => {
19
+ const at = new Date(2024, 5, 12, 12, 0, 1);
20
+
21
+ expect(getCronExecutionKey('0 0 12 * * *', at)).toBeNull();
22
+ });
23
+ });
24
+
6
25
  describe('cronExpressionToBoolean', () => {
7
26
  it('retorna true quando o instante atual coincide com o tick da expressão', () => {
8
27
  const at = new Date(2024, 5, 12, 12, 0, 0, 1);
@@ -3,6 +3,15 @@ import { parseOAuth2ProviderEnv } from '@/core/auth/parse-oauth2-provider-env';
3
3
  import { envSchema, validateEnvConfig } from '@/core/env';
4
4
 
5
5
  describe('envSchema', () => {
6
+ it('define HOST com default 0.0.0.0 para bind em container', () => {
7
+ const env = envSchema.parse({
8
+ NODE_ENV: 'test',
9
+ DATABASE_URL: 'postgres://localhost/test',
10
+ });
11
+
12
+ expect(env.HOST).toBe('0.0.0.0');
13
+ });
14
+
6
15
  it('interpreta CRON_JOBS_ENABLED=false como desabilitado', () => {
7
16
  const env = envSchema.parse({
8
17
  NODE_ENV: 'test',
@@ -12,6 +21,16 @@ describe('envSchema', () => {
12
21
 
13
22
  expect(env.CRON_JOBS_ENABLED).toBe(false);
14
23
  });
24
+
25
+ it('define rate limit desabilitado por padrão', () => {
26
+ const env = envSchema.parse({
27
+ NODE_ENV: 'test',
28
+ DATABASE_URL: 'postgres://localhost/test',
29
+ });
30
+
31
+ expect(env.RATE_LIMIT_MAX).toBe(0);
32
+ expect(env.RATE_LIMIT_WINDOW_MS).toBe(60_000);
33
+ });
15
34
  });
16
35
 
17
36
  describe('parseOAuth2ProviderEnv', () => {