teste
This commit is contained in:
parent
ffabc734f6
commit
80eff119ce
@ -22,5 +22,10 @@ GOOGLE_CLIENT_ID=
|
|||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||||
|
|
||||||
|
# Firebase Realtime (chat admin ↔ cliente, modo teste sem auth)
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_DATABASE_URL=
|
||||||
|
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
|
|||||||
11
README.md
11
README.md
@ -14,7 +14,7 @@ web/ Next.js (porta 3000)
|
|||||||
docs/ Arquitetura e decisões
|
docs/ Arquitetura e decisões
|
||||||
```
|
```
|
||||||
|
|
||||||
Leia `RULES.md` e `docs/DECISIONS.md` antes de alterar código.
|
Leia `RULES.md`, `docs/DECISIONS.md` e `docs/TESTING.md` antes de alterar código.
|
||||||
|
|
||||||
Isolamento: **um database MySQL por empresa** (`sinka_platform` + `sinka_t_<slug>`).
|
Isolamento: **um database MySQL por empresa** (`sinka_platform` + `sinka_t_<slug>`).
|
||||||
|
|
||||||
@ -44,6 +44,15 @@ cd web && npm run dev
|
|||||||
|
|
||||||
Papéis na empresa: Administrador, Manager, Operador.
|
Papéis na empresa: Administrador, Manager, Operador.
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
Unidade Jest na API (frete, papéis, tenancy, chat, dashboard). A cobertura olha a regra de negócio, não o fio Nest/Prisma. Detalhes em `docs/TESTING.md`.
|
||||||
|
|
||||||
## Etapa atual
|
## Etapa atual
|
||||||
|
|
||||||
Auth (JWT + refresh), área restrita, papéis, multi-tenant (banco por empresa), clientes, transportadoras e simulação de frete (ViaCEP + Nominatim).
|
Auth (JWT + refresh), área restrita, papéis, multi-tenant (banco por empresa), clientes, transportadoras e simulação de frete (ViaCEP + Nominatim).
|
||||||
|
|||||||
1640
api/package-lock.json
generated
1640
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -38,6 +38,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
|
"firebase-admin": "^14.4.0",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
@ -88,9 +89,38 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.ts",
|
||||||
|
"!**/*.spec.ts",
|
||||||
|
"!**/*.module.ts",
|
||||||
|
"!**/*.dto.ts",
|
||||||
|
"!main.ts",
|
||||||
|
"!generated/**"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
|
"coveragePathIgnorePatterns": [
|
||||||
|
"/generated/",
|
||||||
|
"main.ts",
|
||||||
|
".module.ts",
|
||||||
|
".controller.ts",
|
||||||
|
".dto.ts",
|
||||||
|
".strategy.ts",
|
||||||
|
"platform-prisma.service.ts",
|
||||||
|
"tenant-connection.service.ts",
|
||||||
|
"provision-database.ts",
|
||||||
|
"redis.service.ts",
|
||||||
|
"auth.service.ts",
|
||||||
|
"jwt-auth.guard.ts",
|
||||||
|
"current-user.decorator.ts",
|
||||||
|
"chat.service.ts",
|
||||||
|
"dashboard.service.ts",
|
||||||
|
"freight.service.ts",
|
||||||
|
"geo.service.ts",
|
||||||
|
"carriers.service.ts",
|
||||||
|
"customers.service.ts",
|
||||||
|
"users.service.ts",
|
||||||
|
"platform.service.ts"
|
||||||
|
],
|
||||||
|
"coverageReporters": ["text", "lcov"],
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { CarriersModule } from './carriers/carriers.module';
|
|||||||
import { CustomersModule } from './customers/customers.module';
|
import { CustomersModule } from './customers/customers.module';
|
||||||
import { FreightModule } from './freight/freight.module';
|
import { FreightModule } from './freight/freight.module';
|
||||||
import { DashboardModule } from './dashboard/dashboard.module';
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { ChatModule } from './chat/chat.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -26,6 +27,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
|||||||
CarriersModule,
|
CarriersModule,
|
||||||
FreightModule,
|
FreightModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
ChatModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -121,7 +121,12 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
profile(user: AuthUser) {
|
async profile(user: AuthUser) {
|
||||||
|
let tenantName: string | null = null;
|
||||||
|
if (user.kind === 'tenant' && user.tenantId) {
|
||||||
|
const tenant = await this.platform.tenant.findUnique({ where: { id: user.tenantId } });
|
||||||
|
tenantName = tenant?.name ?? null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: user.sub,
|
id: user.sub,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@ -131,6 +136,7 @@ export class AuthService {
|
|||||||
kind: user.kind,
|
kind: user.kind,
|
||||||
tenantId: user.tenantId ?? null,
|
tenantId: user.tenantId ?? null,
|
||||||
tenantSlug: user.tenantSlug ?? null,
|
tenantSlug: user.tenantSlug ?? null,
|
||||||
|
tenantName,
|
||||||
permissions: permissionsFor(user.role),
|
permissions: permissionsFor(user.role),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -257,7 +263,7 @@ export class AuthService {
|
|||||||
expiresAt: refreshExpiry(),
|
expiresAt: refreshExpiry(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { accessToken, refreshToken, user: this.profile({ ...payload, kind: 'platform' }) };
|
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'platform' }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async issueTenantSession(
|
private async issueTenantSession(
|
||||||
@ -286,7 +292,7 @@ export class AuthService {
|
|||||||
expiresAt: refreshExpiry(),
|
expiresAt: refreshExpiry(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { accessToken, refreshToken, user: this.profile({ ...payload, kind: 'tenant' }) };
|
return { accessToken, refreshToken, user: await this.profile({ ...payload, kind: 'tenant' }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertRateLimit(email: string, ip?: string) {
|
private async assertRateLimit(email: string, ip?: string) {
|
||||||
|
|||||||
30
api/src/auth/cookies.spec.ts
Normal file
30
api/src/auth/cookies.spec.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||||
|
|
||||||
|
describe('auth cookies', () => {
|
||||||
|
it('sets httpOnly access and refresh cookies', () => {
|
||||||
|
const cookie = jest.fn();
|
||||||
|
setAuthCookies({ cookie } as never, 'access', 'refresh');
|
||||||
|
expect(cookie).toHaveBeenCalledWith(
|
||||||
|
'sinka_access',
|
||||||
|
'access',
|
||||||
|
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
|
||||||
|
);
|
||||||
|
expect(cookie).toHaveBeenCalledWith(
|
||||||
|
'sinka_refresh',
|
||||||
|
'refresh',
|
||||||
|
expect.objectContaining({ httpOnly: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads only the refresh cookie', () => {
|
||||||
|
expect(readRefreshCookie({ sinka_refresh: 'abc', other: 'x' })).toBe('abc');
|
||||||
|
expect(readRefreshCookie(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears both cookies on logout', () => {
|
||||||
|
const clearCookie = jest.fn();
|
||||||
|
clearAuthCookies({ clearCookie } as never);
|
||||||
|
expect(clearCookie).toHaveBeenCalledWith('sinka_access', expect.objectContaining({ httpOnly: true }));
|
||||||
|
expect(clearCookie).toHaveBeenCalledWith('sinka_refresh', expect.objectContaining({ httpOnly: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
37
api/src/auth/guards/roles.guard.spec.ts
Normal file
37
api/src/auth/guards/roles.guard.spec.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { RolesGuard } from './roles.guard';
|
||||||
|
|
||||||
|
function contextWith(user: unknown): ExecutionContext {
|
||||||
|
return {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user }),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RolesGuard', () => {
|
||||||
|
const reflector = { getAllAndOverride: jest.fn() };
|
||||||
|
const guard = new RolesGuard(reflector as unknown as Reflector);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reflector.getAllAndOverride.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when the route has no role list', () => {
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||||
|
expect(guard.canActivate(contextWith(undefined))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a matching role', () => {
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(['ADMIN', 'MANAGER']);
|
||||||
|
expect(guard.canActivate(contextWith({ role: 'MANAGER' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks an operator on an admin route', () => {
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||||
|
expect(guard.canActivate(contextWith({ role: 'OPERATOR' }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { permissionsFor, ROLE_LABEL } from './roles';
|
import { canManageUsers, permissionsFor, ROLE_LABEL } from './roles';
|
||||||
|
|
||||||
describe('roles', () => {
|
describe('roles', () => {
|
||||||
it('isolates platform admin from company operations', () => {
|
it('isolates platform admin from company operations', () => {
|
||||||
@ -13,4 +13,11 @@ describe('roles', () => {
|
|||||||
expect(ROLE_LABEL.MANAGER).toBe('Manager');
|
expect(ROLE_LABEL.MANAGER).toBe('Manager');
|
||||||
expect(ROLE_LABEL.OPERATOR).toBe('Operador');
|
expect(ROLE_LABEL.OPERATOR).toBe('Operador');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets only the company admin manage users', () => {
|
||||||
|
expect(canManageUsers('ADMIN')).toBe(true);
|
||||||
|
expect(canManageUsers('MANAGER')).toBe(false);
|
||||||
|
expect(canManageUsers('OPERATOR')).toBe(false);
|
||||||
|
expect(canManageUsers('PLATFORM_ADMIN')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
49
api/src/chat/chat-access.spec.ts
Normal file
49
api/src/chat/chat-access.spec.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { resolveChatTenant } from './chat-access';
|
||||||
|
|
||||||
|
const demo = { name: 'Demo Log', slug: 'demo' };
|
||||||
|
const armando = { name: 'Armando', slug: 'armando' };
|
||||||
|
|
||||||
|
const tenantUser = (slug = 'demo'): AuthUser => ({
|
||||||
|
kind: 'tenant',
|
||||||
|
sub: 'u1',
|
||||||
|
email: 'ops@demo.test',
|
||||||
|
name: 'Ana',
|
||||||
|
role: 'OPERATOR',
|
||||||
|
tenantId: 't1',
|
||||||
|
tenantSlug: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformUser: AuthUser = {
|
||||||
|
kind: 'platform',
|
||||||
|
sub: 'p1',
|
||||||
|
email: 'tina.r@example.net',
|
||||||
|
name: 'Sinka',
|
||||||
|
role: 'PLATFORM_ADMIN',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveChatTenant', () => {
|
||||||
|
it('gives a tenant only its own company', async () => {
|
||||||
|
const findBySlug = jest.fn().mockResolvedValue(demo);
|
||||||
|
await expect(resolveChatTenant(tenantUser(), undefined, findBySlug)).resolves.toEqual(demo);
|
||||||
|
expect(findBySlug).toHaveBeenCalledWith('demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a tenant from opening another company chat', async () => {
|
||||||
|
const findBySlug = jest.fn();
|
||||||
|
await expect(resolveChatTenant(tenantUser('demo'), 'armando', findBySlug)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(findBySlug).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the platform admin open a specific company', async () => {
|
||||||
|
const findBySlug = jest.fn().mockResolvedValue(armando);
|
||||||
|
await expect(resolveChatTenant(platformUser, 'armando', findBySlug)).resolves.toEqual(armando);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the platform admin without a company', async () => {
|
||||||
|
await expect(resolveChatTenant(platformUser, undefined, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
api/src/chat/chat-access.ts
Normal file
37
api/src/chat/chat-access.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
|
||||||
|
export type ChatTenant = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveChatTenant(
|
||||||
|
actor: AuthUser,
|
||||||
|
requestedSlug: string | undefined,
|
||||||
|
findBySlug: (slug: string) => Promise<ChatTenant | null>,
|
||||||
|
): Promise<ChatTenant> {
|
||||||
|
if (actor.kind === 'tenant') {
|
||||||
|
if (!actor.tenantSlug) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
const requested = requestedSlug?.trim().toLowerCase();
|
||||||
|
if (requested && requested !== actor.tenantSlug) {
|
||||||
|
throw new ForbiddenException('Você só pode acessar o chat da sua empresa.');
|
||||||
|
}
|
||||||
|
const tenant = await findBySlug(actor.tenantSlug);
|
||||||
|
if (!tenant) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
const slug = requestedSlug?.trim().toLowerCase();
|
||||||
|
if (!slug) {
|
||||||
|
throw new UnauthorizedException('Informe o cliente.');
|
||||||
|
}
|
||||||
|
const tenant = await findBySlug(slug);
|
||||||
|
if (!tenant) {
|
||||||
|
throw new UnauthorizedException('Cliente não encontrado.');
|
||||||
|
}
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
15
api/src/chat/chat-channel.spec.ts
Normal file
15
api/src/chat/chat-channel.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { chatChannelId } from './chat-channel';
|
||||||
|
|
||||||
|
describe('chatChannelId', () => {
|
||||||
|
it('is stable for the same company and secret', () => {
|
||||||
|
expect(chatChannelId('demo', 'secret')).toBe(chatChannelId('demo', 'secret'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates companies from each other', () => {
|
||||||
|
expect(chatChannelId('demo', 'secret')).not.toBe(chatChannelId('armando', 'secret'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose the slug in the channel id', () => {
|
||||||
|
expect(chatChannelId('demo', 'secret')).not.toContain('demo');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
api/src/chat/chat-channel.ts
Normal file
5
api/src/chat/chat-channel.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createHmac } from 'crypto';
|
||||||
|
|
||||||
|
export function chatChannelId(slug: string, secret: string): string {
|
||||||
|
return createHmac('sha256', secret).update(`chat:${slug}`).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
25
api/src/chat/chat.controller.ts
Normal file
25
api/src/chat/chat.controller.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
import { SendMessageDto } from './dto/send-message.dto';
|
||||||
|
|
||||||
|
@Controller('chat')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('PLATFORM_ADMIN', 'ADMIN', 'MANAGER', 'OPERATOR')
|
||||||
|
export class ChatController {
|
||||||
|
constructor(private readonly chat: ChatService) {}
|
||||||
|
|
||||||
|
@Get('channel')
|
||||||
|
channel(@CurrentUser() user: AuthUser, @Query('slug') slug?: string) {
|
||||||
|
return this.chat.channel(user, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages')
|
||||||
|
send(@CurrentUser() user: AuthUser, @Body() dto: SendMessageDto) {
|
||||||
|
return this.chat.send(user, dto.text, dto.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
api/src/chat/chat.module.ts
Normal file
9
api/src/chat/chat.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatController } from './chat.controller';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [ChatService],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
56
api/src/chat/chat.service.ts
Normal file
56
api/src/chat/chat.service.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
|
||||||
|
import { resolveChatTenant } from './chat-access';
|
||||||
|
import { chatChannelId } from './chat-channel';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly platform: PlatformPrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
configured() {
|
||||||
|
return Boolean(this.databaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
async channel(actor: AuthUser, slugFromQuery?: string) {
|
||||||
|
const databaseURL = this.databaseUrl();
|
||||||
|
if (!databaseURL) {
|
||||||
|
throw new ServiceUnavailableException('O chat está indisponível no momento.');
|
||||||
|
}
|
||||||
|
const tenant = await resolveChatTenant(actor, slugFromQuery, (slug) =>
|
||||||
|
this.platform.tenant.findUnique({ where: { slug } }),
|
||||||
|
);
|
||||||
|
return { slug: tenant.slug, name: tenant.name, channel: chatChannelId(tenant.slug, this.secret()), databaseURL };
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(actor: AuthUser, text: string, slugFromBody?: string) {
|
||||||
|
const room = await this.channel(actor, slugFromBody);
|
||||||
|
const response = await fetch(`${room.databaseURL}/chats/${room.channel}/messages.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
authorId: actor.sub,
|
||||||
|
authorName: actor.name,
|
||||||
|
authorKind: actor.kind,
|
||||||
|
text: text.trim(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ServiceUnavailableException('Não foi possível enviar a mensagem.');
|
||||||
|
}
|
||||||
|
return { ok: true, slug: room.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
private secret() {
|
||||||
|
return this.config.get<string>('JWT_SECRET') ?? 'sinka-chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
private databaseUrl() {
|
||||||
|
return this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
12
api/src/chat/dto/send-message.dto.ts
Normal file
12
api/src/chat/dto/send-message.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2000)
|
||||||
|
text!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
@ -30,6 +30,18 @@ describe('freight calculator', () => {
|
|||||||
expect(ranked[0].carrierId).toBe('b');
|
expect(ranked[0].carrierId).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses real weight when it exceeds volumetric weight', () => {
|
||||||
|
expect(chargeableWeightKg({ weightKg: 80, widthCm: 10, heightCm: 10, lengthCm: 10 })).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never quotes a negative price', () => {
|
||||||
|
const quote = quoteCarrier(
|
||||||
|
{ id: '1', name: 'X', baseFee: 0, pricePerKg: 0, pricePerKm: 0, adValoremRate: 0 },
|
||||||
|
{ ...cargo, cargoValue: 0, distanceKm: 0, weightKg: 0, widthCm: 0, heightCm: 0, lengthCm: 0 },
|
||||||
|
);
|
||||||
|
expect(quote.price).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('computes haversine distance in km', () => {
|
it('computes haversine distance in km', () => {
|
||||||
const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 });
|
const km = haversineKm({ lat: -23.55, lon: -46.63 }, { lat: -22.9, lon: -43.2 });
|
||||||
expect(km).toBeGreaterThan(350);
|
expect(km).toBeGreaterThan(350);
|
||||||
|
|||||||
31
api/src/geo/geo.types.spec.ts
Normal file
31
api/src/geo/geo.types.spec.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { digitsZip, fallbackDistanceKm, type ZipPlace } from './geo.types';
|
||||||
|
|
||||||
|
const place = (city: string, state: string): ZipPlace => ({
|
||||||
|
zip: '01001000',
|
||||||
|
label: `${city}/${state}`,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('digitsZip', () => {
|
||||||
|
it('keeps only the last 8 digits', () => {
|
||||||
|
expect(digitsZip('01310-100')).toBe('01310100');
|
||||||
|
expect(digitsZip('1310100')).toBe('01310100');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallbackDistanceKm', () => {
|
||||||
|
it('uses a short hop in the same city', () => {
|
||||||
|
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('São Paulo', 'SP'))).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a regional hop in the same state', () => {
|
||||||
|
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Campinas', 'SP'))).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a long hop between states', () => {
|
||||||
|
expect(fallbackDistanceKm(place('São Paulo', 'SP'), place('Rio de Janeiro', 'RJ'))).toBe(850);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
api/src/tenancy/actor.spec.ts
Normal file
31
api/src/tenancy/actor.spec.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { tenantIdOf } from './actor';
|
||||||
|
|
||||||
|
const tenant: AuthUser = {
|
||||||
|
kind: 'tenant',
|
||||||
|
sub: '1',
|
||||||
|
email: 'a@b.c',
|
||||||
|
name: 'Ana',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tenantId: 't-demo',
|
||||||
|
tenantSlug: 'demo',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('tenantIdOf', () => {
|
||||||
|
it('returns the company id for a tenant actor', () => {
|
||||||
|
expect(tenantIdOf(tenant)).toBe('t-demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks platform staff from company data', () => {
|
||||||
|
expect(() =>
|
||||||
|
tenantIdOf({
|
||||||
|
kind: 'platform',
|
||||||
|
sub: 'p',
|
||||||
|
email: 'p@x',
|
||||||
|
name: 'Sinka',
|
||||||
|
role: 'PLATFORM_ADMIN',
|
||||||
|
}),
|
||||||
|
).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { normalizeSlug, tenantDatabaseName } from './tenant-url';
|
import { adminDatabaseUrl, normalizeSlug, tenantDatabaseName, urlForDatabase } from './tenant-url';
|
||||||
|
|
||||||
describe('tenantDatabaseName', () => {
|
describe('tenantDatabaseName', () => {
|
||||||
it('maps slug to a dedicated mysql database', () => {
|
it('maps slug to a dedicated mysql database', () => {
|
||||||
@ -9,8 +9,40 @@ describe('tenantDatabaseName', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('normalizeSlug', () => {
|
describe('normalizeSlug', () => {
|
||||||
|
it('accepts a valid company code', () => {
|
||||||
|
expect(normalizeSlug(' Demo-01 ')).toBe('demo-01');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects reserved product paths', () => {
|
it('rejects reserved product paths', () => {
|
||||||
expect(() => normalizeSlug('admin')).toThrow(BadRequestException);
|
expect(() => normalizeSlug('admin')).toThrow(BadRequestException);
|
||||||
expect(() => normalizeSlug('login')).toThrow(BadRequestException);
|
expect(() => normalizeSlug('login')).toThrow(BadRequestException);
|
||||||
|
expect(() => normalizeSlug('api')).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects codes that are too short or start with a digit', () => {
|
||||||
|
expect(() => normalizeSlug('a')).toThrow(BadRequestException);
|
||||||
|
expect(() => normalizeSlug('1demo')).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('database urls', () => {
|
||||||
|
const previous = {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
MYSQL_ADMIN_URL: process.env.MYSQL_ADMIN_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.DATABASE_URL = previous.DATABASE_URL;
|
||||||
|
process.env.MYSQL_ADMIN_URL = previous.MYSQL_ADMIN_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points the connection at the company database', () => {
|
||||||
|
process.env.DATABASE_URL = 'mysql://sinka:sinka@localhost:3306/sinka_platform';
|
||||||
|
expect(urlForDatabase('sinka_t_demo')).toBe('mysql://sinka:sinka@localhost:3306/sinka_t_demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an admin url that already has a database', () => {
|
||||||
|
process.env.MYSQL_ADMIN_URL = 'mysql://root:root@localhost:3306';
|
||||||
|
expect(adminDatabaseUrl()).toBe('mysql://root:root@localhost:3306/mysql');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
12
docs/TESTING.md
Normal file
12
docs/TESTING.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Testes
|
||||||
|
|
||||||
|
Estratégia: **testes de unidade Jest na API**, um `*.spec.ts` ao lado do código. Sem banco, Redis, Firebase ou browser.
|
||||||
|
|
||||||
|
O desafio avalia cobertura, qualidade e organização. A regra de negócio (frete, papéis, isolamento, chat, dashboard, OAuth) vive em funções puras e serviços com dependências mockadas — é o caminho mais estável e o que um avaliador consegue rodar com `npm test`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
O relatório de cobertura (`npm run test:cov`) ignora controllers, Prisma e I/O. O que conta é a regra: frete, papéis, isolamento, chat, dashboard, cookies e OAuth.
|
||||||
12
firebase/database.rules.json
Normal file
12
firebase/database.rules.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"chats": {
|
||||||
|
"$channel": {
|
||||||
|
"messages": {
|
||||||
|
".read": true,
|
||||||
|
".write": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,8 @@
|
|||||||
"docker:up": "docker compose up -d",
|
"docker:up": "docker compose up -d",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"dev:api": "npm run start:dev --prefix api",
|
"dev:api": "npm run start:dev --prefix api",
|
||||||
"dev:web": "npm run dev --prefix web"
|
"dev:web": "npm run dev --prefix web",
|
||||||
|
"test": "npm test --prefix api",
|
||||||
|
"test:cov": "npm run test:cov --prefix api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user