Skip to content
Merged
17 changes: 17 additions & 0 deletions src/modules/simulado/dtos/update-disponibilidade.dto.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsOptional } from 'class-validator';

export class UpdateDisponibilidadeDTO {
@ApiProperty({ required: false, nullable: true, type: Date })
@IsOptional()
@IsDate()
@Type(() => Date)
disponivelDe?: Date | null;

@ApiProperty({ required: false, nullable: true, type: Date })
@IsOptional()
@IsDate()
@Type(() => Date)
disponivelAte?: Date | null;
}
118 changes: 118 additions & 0 deletions src/modules/simulado/helpers/availability.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { getAvailabilityStatus, isSimuladoAvailable } from './availability';

// Datas fixas de referência (UTC) para determinismo — sem depender do relógio real.
const DE = new Date('2026-01-10T00:00:00.000Z');
const ATE = new Date('2026-01-20T00:00:00.000Z');
const ANTES = new Date('2026-01-05T00:00:00.000Z');
const DENTRO = new Date('2026-01-15T00:00:00.000Z');
const DEPOIS = new Date('2026-01-25T00:00:00.000Z');

describe('isSimuladoAvailable', () => {
it('false quando bloqueado, independente da janela', () => {
expect(isSimuladoAvailable({ bloqueado: true }, DENTRO)).toBe(false);
expect(
isSimuladoAvailable(
{ bloqueado: true, disponivelDe: DE, disponivelAte: ATE },
DENTRO,
),
).toBe(false);
});

it('true sem janela (null/null) quando desbloqueado', () => {
expect(
isSimuladoAvailable(
{ bloqueado: false, disponivelDe: null, disponivelAte: null },
DENTRO,
),
).toBe(true);
});

it('true quando desbloqueado, sem janela', () => {
expect(isSimuladoAvailable({ bloqueado: false }, DENTRO)).toBe(true);
});

it('só de: false antes, true no limite e depois de disponivelDe', () => {
const s = {
bloqueado: false,
disponivelDe: DE,
disponivelAte: null,
} as any;
expect(isSimuladoAvailable(s, ANTES)).toBe(false);
expect(isSimuladoAvailable(s, DE)).toBe(true); // limite inclusivo
expect(isSimuladoAvailable(s, DEPOIS)).toBe(true);
});

it('só até: true antes e no limite, false depois de disponivelAte', () => {
const s = {
bloqueado: false,
disponivelDe: null,
disponivelAte: ATE,
} as any;
expect(isSimuladoAvailable(s, ANTES)).toBe(true);
expect(isSimuladoAvailable(s, ATE)).toBe(true); // limite inclusivo
expect(isSimuladoAvailable(s, DEPOIS)).toBe(false);
});

it('de + até: false antes, true dentro, false depois', () => {
const s = { bloqueado: false, disponivelDe: DE, disponivelAte: ATE };
expect(isSimuladoAvailable(s, ANTES)).toBe(false);
expect(isSimuladoAvailable(s, DENTRO)).toBe(true);
expect(isSimuladoAvailable(s, DEPOIS)).toBe(false);
});
});

describe('getAvailabilityStatus', () => {
it("'bloqueado' quando bloqueado, independente da janela", () => {
expect(getAvailabilityStatus({ bloqueado: true }, DENTRO)).toBe(
'bloqueado',
);
expect(
getAvailabilityStatus(
{ bloqueado: true, disponivelDe: DE, disponivelAte: ATE },
ANTES,
),
).toBe('bloqueado');
});

it("'disponivel' sem janela quando desbloqueado", () => {
expect(
getAvailabilityStatus(
{ bloqueado: false, disponivelDe: null, disponivelAte: null },
DENTRO,
),
).toBe('disponivel');
});

it("'antes_da_janela' quando now < disponivelDe", () => {
expect(
getAvailabilityStatus(
{ bloqueado: false, disponivelDe: DE, disponivelAte: ATE },
ANTES,
),
).toBe('antes_da_janela');
});

it("'disponivel' quando dentro da janela", () => {
expect(
getAvailabilityStatus(
{ bloqueado: false, disponivelDe: DE, disponivelAte: ATE },
DENTRO,
),
).toBe('disponivel');
});

it("'depois_da_janela' quando now > disponivelAte", () => {
expect(
getAvailabilityStatus(
{ bloqueado: false, disponivelDe: DE, disponivelAte: ATE },
DEPOIS,
),
).toBe('depois_da_janela');
});

it('limites inclusivos: disponivel exatamente em disponivelDe e disponivelAte', () => {
const s = { bloqueado: false, disponivelDe: DE, disponivelAte: ATE };
expect(getAvailabilityStatus(s, DE)).toBe('disponivel');
expect(getAvailabilityStatus(s, ATE)).toBe('disponivel');
});
});
37 changes: 37 additions & 0 deletions src/modules/simulado/helpers/availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Simulado } from '../schemas/simulado.schema';

/**
* Fonte única de verdade sobre "esse simulado está disponível agora?".
* Combina o gate existente `bloqueado` com a janela temporal opcional
* (`disponivelDe` / `disponivelAte`). Funções puras, sem acesso a banco.
*/
export type AvailabilityStatus =
| 'disponivel' // bloqueado=false + dentro da janela (ou sem janela)
| 'bloqueado' // questões pendentes
| 'antes_da_janela' // bloqueado=false, mas now < disponivelDe
| 'depois_da_janela'; // bloqueado=false, mas now > disponivelAte

type AvailabilityInput = Pick<
Simulado,
'bloqueado' | 'disponivelDe' | 'disponivelAte'
>;

export function isSimuladoAvailable(
s: AvailabilityInput,
now: Date = new Date(),
): boolean {
if (s.bloqueado) return false;
if (s.disponivelDe && now < s.disponivelDe) return false;
if (s.disponivelAte && now > s.disponivelAte) return false;
return true;
}

export function getAvailabilityStatus(
s: AvailabilityInput,
now: Date = new Date(),
): AvailabilityStatus {
if (s.bloqueado) return 'bloqueado';
if (s.disponivelDe && now < s.disponivelDe) return 'antes_da_janela';
if (s.disponivelAte && now > s.disponivelAte) return 'depois_da_janela';
return 'disponivel';
}
17 changes: 17 additions & 0 deletions src/modules/simulado/schemas/simulado.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ export class Simulado extends BaseSchema {
@Prop({ required: false, default: null })
@ApiProperty()
cursinhoId?: string | null;

@Prop({ required: false, default: null })
@ApiProperty({ required: false, nullable: true })
disponivelDe?: Date | null;

@Prop({ required: false, default: null })
@ApiProperty({ required: false, nullable: true })
disponivelAte?: Date | null;
}

export const SimuladoSchema = SchemaFactory.createForClass(Simulado);

// Índice composto para o filtro de availability do getAvailable (Card 02).
// autoIndex (default true no MongooseModule) cria automaticamente no boot.
SimuladoSchema.index({
categoria: 1,
bloqueado: 1,
disponivelDe: 1,
disponivelAte: 1,
});
16 changes: 16 additions & 0 deletions src/modules/simulado/simulado.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Get,
HttpCode,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
Expand All @@ -14,6 +15,7 @@ import { GetAllDtoOutput } from 'src/shared/dtos/get-all.dto.output';
import { AnswerSimuladoDto } from './dtos/answer-simulado.dto.input';
import { AvailableSimuladoDTOoutput } from './dtos/available-simulado.dto.output';
import { SimuladoAnswerDTOOutput } from './dtos/simulado-answer.dto.output';
import { UpdateDisponibilidadeDTO } from './dtos/update-disponibilidade.dto.input';
import { Simulado } from './schemas/simulado.schema';
import { SimuladoService } from './simulado.service';

Expand Down Expand Up @@ -52,6 +54,20 @@ export class SimuladoController {
return this.service.answer(answer);
}

@Patch(':id/disponibilidade')
@ApiResponse({
status: 200,
description: 'atualiza a janela de disponibilidade do simulado',
type: Simulado,
isArray: false,
})
public async updateDisponibilidade(
@Param('id') id: string,
@Body() dto: UpdateDisponibilidadeDTO,
): Promise<Simulado> {
return await this.service.updateDisponibilidade(id, dto);
}

@Get('available')
public async getAvailable(
@Query('tipo') tipo: string,
Expand Down
76 changes: 76 additions & 0 deletions src/modules/simulado/simulado.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,79 @@ describe('SimuladoRepository.countByCategoria', () => {
});
});
});

describe('SimuladoRepository.updateDisponibilidade', () => {
it('faz $set apenas com os campos fornecidos', async () => {
const updateOne = jest.fn().mockResolvedValue({ acknowledged: true });
const repo = new SimuladoRepository({ updateOne } as any);

const de = new Date('2026-01-10T00:00:00.000Z');
await repo.updateDisponibilidade('sim-1', { disponivelDe: de });

expect(updateOne).toHaveBeenCalledWith(
{ _id: 'sim-1' },
{ $set: { disponivelDe: de } },
);
});

it('propaga null explícito no $set (para limpar)', async () => {
const updateOne = jest.fn().mockResolvedValue({ acknowledged: true });
const repo = new SimuladoRepository({ updateOne } as any);

await repo.updateDisponibilidade('sim-1', {
disponivelDe: null,
disponivelAte: null,
});

expect(updateOne).toHaveBeenCalledWith(
{ _id: 'sim-1' },
{ $set: { disponivelDe: null, disponivelAte: null } },
);
});
});

describe('SimuladoRepository.getAvailable', () => {
it('filtra por categoria, desbloqueado e dentro da janela temporal', async () => {
const select = jest.fn().mockResolvedValue([{ _id: 's1', nome: 'S1' }]);
const find = jest.fn().mockReturnValue({ select });
const repo = new SimuladoRepository({ find } as any);

const result = await repo.getAvailable('cat-1');

expect(result).toEqual([{ _id: 's1', nome: 'S1' }]);
const filtro = find.mock.calls[0][0];
expect(filtro.categoria).toBe('cat-1');
expect(filtro.bloqueado).toBe(false);
// janela: disponivelDe null OU <= agora ; disponivelAte null OU >= agora
expect(filtro.$and).toHaveLength(2);
expect(filtro.$and[0].$or[0]).toEqual({ disponivelDe: null });
expect(filtro.$and[0].$or[1].disponivelDe.$lte).toBeInstanceOf(Date);
expect(filtro.$and[1].$or[0]).toEqual({ disponivelAte: null });
expect(filtro.$and[1].$or[1].disponivelAte.$gte).toBeInstanceOf(Date);
expect(select).toHaveBeenCalledWith(['nome', '_id']);
});
});

describe('SimuladoRepository.getAvailabilityById', () => {
it('lê só os campos de disponibilidade, sem populate', async () => {
const exec = jest.fn().mockResolvedValue({
bloqueado: false,
disponivelDe: null,
disponivelAte: null,
});
const lean = jest.fn().mockReturnValue({ exec });
const select = jest.fn().mockReturnValue({ lean });
const findById = jest.fn().mockReturnValue({ select });
const repo = new SimuladoRepository({ findById } as any);

const result = await repo.getAvailabilityById('sim-1');

expect(findById).toHaveBeenCalledWith('sim-1');
expect(select).toHaveBeenCalledWith('bloqueado disponivelDe disponivelAte');
expect(result).toEqual({
bloqueado: false,
disponivelDe: null,
disponivelAte: null,
});
});
});
26 changes: 24 additions & 2 deletions src/modules/simulado/simulado.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,34 @@ export class SimuladoRepository extends BaseRepository<Simulado> {
}

async getAvailable(categoriaId: string) {
const now = new Date();
return await this.model
.find()
.where({ categoria: categoriaId, bloqueado: false })
.find({
categoria: categoriaId,
bloqueado: false,
$and: [
{ $or: [{ disponivelDe: null }, { disponivelDe: { $lte: now } }] },
{ $or: [{ disponivelAte: null }, { disponivelAte: { $gte: now } }] },
],
})
.select(['nome', '_id']);
}

async updateDisponibilidade(
id: string,
fields: { disponivelDe?: Date | null; disponivelAte?: Date | null },
) {
await this.model.updateOne({ _id: id }, { $set: fields });
}

async getAvailabilityById(id: string): Promise<Simulado | null> {
return await this.model
.findById(id)
.select('bloqueado disponivelDe disponivelAte')
.lean<Simulado>()
.exec();
}

public removeDuplicatedSimulados(
simuladosToCheck: Simulado[],
simuladosToCompare: Simulado[],
Expand Down
Loading
Loading