From a60387c2afdd96afa386702e76ba1d0ca09ba0f9 Mon Sep 17 00:00:00 2001 From: Edvaldo Szymonek Date: Sun, 27 Sep 2026 11:57:22 -0300 Subject: [PATCH] =?UTF-8?q?cria=20estrutura=20para=20permsist=C3=AAncia=20?= =?UTF-8?q?da=20sess=C3=A3o=20de=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/type-and-file-names.mdc | 4 +- ...0927120000_cria_tabela_usuarios_sessoes.ts | 21 ++ .../ApagaUsuarioSessaoUseCase.ts | 19 ++ .../ApagaUsuarioSessoesUseCase.ts | 19 ++ .../BuscaUsuarioSessaoPorHashUseCase.ts | 52 +++++ .../BuscaUsuarioSessaoPorIdUseCase.ts | 52 +++++ .../usuarioSessao/CriaUsuarioSessaoUseCase.ts | 64 +++++++ .../RotacionaUsuarioSessaoUseCase.ts | 83 ++++++++ src/domain/usuarioSessao/UsuarioSessao.ts | 62 ++++++ .../usuarioSessao/UsuarioSessaoCollection.ts | 18 ++ .../error/UserSessionNotFoundError.ts | 10 + src/factory/UsuarioSessaoCollectionFactory.ts | 8 + .../UsuarioSessaoCollectionKnexAdapter.ts | 157 +++++++++++++++ .../ApagaUsuarioSessaoUseCase.test.ts | 33 ++++ .../ApagaUsuarioSessoesUseCase.test.ts | 37 ++++ .../BuscaUsuarioSessaoPorHashUseCase.test.ts | 64 +++++++ .../BuscaUsuarioSessaoPorIdUseCase.test.ts | 57 ++++++ .../CriaUsuarioSessaoUseCase.test.ts | 69 +++++++ .../FakeUsuarioSessaoCollection.ts | 69 +++++++ .../RotacionaUsuarioSessaoUseCase.test.ts | 100 ++++++++++ ...UsuarioSessaoCollectionKnexAdapter.test.ts | 179 ++++++++++++++++++ 21 files changed, 1175 insertions(+), 2 deletions(-) create mode 100644 src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts create mode 100644 src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts create mode 100644 src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts create mode 100644 src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts create mode 100644 src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts create mode 100644 src/domain/usuarioSessao/UsuarioSessao.ts create mode 100644 src/domain/usuarioSessao/UsuarioSessaoCollection.ts create mode 100644 src/domain/usuarioSessao/error/UserSessionNotFoundError.ts create mode 100644 src/factory/UsuarioSessaoCollectionFactory.ts create mode 100644 src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts create mode 100644 test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts create mode 100644 test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts create mode 100644 test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts create mode 100644 test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts diff --git a/.cursor/rules/type-and-file-names.mdc b/.cursor/rules/type-and-file-names.mdc index 415914eb..802ad76d 100644 --- a/.cursor/rules/type-and-file-names.mdc +++ b/.cursor/rules/type-and-file-names.mdc @@ -21,9 +21,9 @@ The same conjugation rule applies in Portuguese and in English. Keep the languag | --- | --- | | List a collection | `{Verb}{Resources}Controller` / `{Verb}{Resources}UseCase` | | Get one by id | `{Verb}{Resource}UseCase` (and matching controller) | -| Tests | One file per action; kebab-case of the same verb + resource stem | +| Tests | One file per type: `{TypeName}.test.ts` | -Type files are PascalCase and match the exported type. Test files use kebab-case of that stem (for example `lists-countries.test.ts`), not a different verb and not several actions in one file. +Type files are PascalCase and match the exported type. Test files use the same TitleCase name as the type (for example `ListaPaisesUseCase.test.ts`). Folders use camelCase when the name is compound (for example `src/domain/usuarioSessao`, `src/domain/faseSucessional`). Tests live under the same folder name as `src` (for example `test/unit/domain/usuarioSessao/`). Do not use kebab-case or TitleCase for those folders. ## When renaming diff --git a/src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts b/src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts new file mode 100644 index 00000000..f14d93ca --- /dev/null +++ b/src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + await knex.transaction(async trx => { + await trx.schema.createTable('usuarios_sessoes', table => { + table.uuid('id').primary().defaultTo(trx.raw('gen_random_uuid()')) + + table.integer('usuario_id').notNullable() + table.foreign('usuario_id') + .references('id') + .inTable('usuarios') + table.index('usuario_id') + + table.string('refresh_token_hash', 64).notNullable().unique() + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('last_used_at').notNullable() + table.timestamp('expires_at').notNullable() + }) + }) +} diff --git a/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..e4876fd3 --- /dev/null +++ b/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts @@ -0,0 +1,19 @@ +import { type Either } from '@/library/either/Either' + +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection +} + +export class ApagaUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + } + + async execute(params: { id: string }): Promise> { + return this.usuarioSessaoCollection.deleteById(params.id) + } +} diff --git a/src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts b/src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts new file mode 100644 index 00000000..dc017472 --- /dev/null +++ b/src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts @@ -0,0 +1,19 @@ +import { type Either } from '@/library/either/Either' + +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection +} + +export class ApagaUsuarioSessoesUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + } + + async execute(params: { usuarioId: number }): Promise> { + return this.usuarioSessaoCollection.deleteByUsuarioId(params.usuarioId) + } +} diff --git a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts new file mode 100644 index 00000000..3bf76467 --- /dev/null +++ b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts @@ -0,0 +1,52 @@ +import { Either } from '@/library/either/Either' + +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + now?: () => Date +} + +export class BuscaUsuarioSessaoPorHashUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { refreshTokenHash: string }): Promise> { + const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(params.refreshTokenHash) + if (found.left()) { + return found + } + if (!found.value) { + return Either.right(null) + } + + const expired = await this.deleteIfExpired(found.value) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.right(null) + } + + return Either.right(found.value) + } + + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts new file mode 100644 index 00000000..f64a30b2 --- /dev/null +++ b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts @@ -0,0 +1,52 @@ +import { Either } from '@/library/either/Either' + +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + now?: () => Date +} + +export class BuscaUsuarioSessaoPorIdUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { id: string }): Promise> { + const found = await this.usuarioSessaoCollection.findById(params.id) + if (found.left()) { + return found + } + if (!found.value) { + return Either.right(null) + } + + const expired = await this.deleteIfExpired(found.value) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.right(null) + } + + return Either.right(found.value) + } + + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..11b59d47 --- /dev/null +++ b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts @@ -0,0 +1,64 @@ +import { randomUUID } from 'node:crypto' + +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + refreshToken: RefreshToken + now?: () => Date +} + +export class CriaUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly refreshToken: RefreshToken + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.refreshToken = dependencies.refreshToken + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { usuarioId: number }): Promise> { + const generated = this.refreshToken.generate() + if (generated.left()) { + return generated + } + + const createdAt = this.now() + const attributes: Attributes = { + id: randomUUID(), + usuarioId: params.usuarioId, + refreshTokenHash: generated.value.hash, + createdAt, + lastUsedAt: createdAt, + expiresAt: UsuarioSessao.refreshExpiresAt(createdAt) + } + + const session = UsuarioSessao.create(attributes) + if (session.left()) { + return session + } + + const persisted = await this.usuarioSessaoCollection.create({ + id: session.value.id, + usuarioId: session.value.usuarioId, + refreshTokenHash: session.value.refreshTokenHash, + createdAt: session.value.createdAt, + lastUsedAt: session.value.lastUsedAt, + expiresAt: session.value.expiresAt + }) + if (persisted.left()) { + return persisted + } + + return Either.right({ + session: persisted.value, + refreshToken: generated.value.token + }) + } +} diff --git a/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..2d195fad --- /dev/null +++ b/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts @@ -0,0 +1,83 @@ +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + refreshToken: RefreshToken + now?: () => Date +} + +export class RotacionaUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly refreshToken: RefreshToken + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.refreshToken = dependencies.refreshToken + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { refreshToken: string }): Promise> { + const hashed = this.refreshToken.hash(params.refreshToken) + if (hashed.left()) { + return hashed + } + + const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(hashed.value) + if (found.left()) { + return found + } + if (!found.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const now = this.now() + const expired = await this.deleteIfExpired(found.value, now) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const generated = this.refreshToken.generate() + if (generated.left()) { + return generated + } + + const updated = await this.usuarioSessaoCollection.updateRotation(found.value.id, { + refreshTokenHash: generated.value.hash, + lastUsedAt: now, + expiresAt: UsuarioSessao.refreshExpiresAt(now) + }) + if (updated.left()) { + return updated + } + if (!updated.value) { + return Either.left(new UserSessionNotFoundError()) + } + + return Either.right({ + session: updated.value, + refreshToken: generated.value.token + }) + } + + private async deleteIfExpired(session: Attributes, now: Date): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, now)) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/UsuarioSessao.ts b/src/domain/usuarioSessao/UsuarioSessao.ts new file mode 100644 index 00000000..7e64eb7a --- /dev/null +++ b/src/domain/usuarioSessao/UsuarioSessao.ts @@ -0,0 +1,62 @@ +import { Either } from '@/library/either/Either' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const REFRESH_TOKEN_HASH_PATTERN = /^[0-9a-f]{64}$/ +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 + +export interface Attributes { + id: string + usuarioId: number + refreshTokenHash: string + createdAt: Date + lastUsedAt: Date + expiresAt: Date +} + +export class UsuarioSessao { + static readonly REFRESH_TTL_DAYS = 30 + + readonly id: string + readonly usuarioId: number + readonly refreshTokenHash: string + readonly createdAt: Date + readonly lastUsedAt: Date + readonly expiresAt: Date + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.usuarioId = attributes.usuarioId + this.refreshTokenHash = attributes.refreshTokenHash + this.createdAt = attributes.createdAt + this.lastUsedAt = attributes.lastUsedAt + this.expiresAt = attributes.expiresAt + } + + static refreshExpiresAt(now: Date): Date { + return new Date(now.getTime() + UsuarioSessao.REFRESH_TTL_DAYS * MILLISECONDS_PER_DAY) + } + + static expired(expiresAt: Date, now: Date): boolean { + return expiresAt.getTime() <= now.getTime() + } + + static create(attributes: Attributes): Either { + if (!UUID_PATTERN.test(attributes.id)) { + return Either.left(new Error('Id da sessão deve ser um UUID')) + } + + if (!Number.isInteger(attributes.usuarioId) || attributes.usuarioId <= 0) { + return Either.left(new Error('usuarioId da sessão deve ser um inteiro positivo')) + } + + if (!REFRESH_TOKEN_HASH_PATTERN.test(attributes.refreshTokenHash)) { + return Either.left(new Error('refreshTokenHash da sessão deve ser SHA-256 hex')) + } + + if (attributes.expiresAt.getTime() <= attributes.createdAt.getTime()) { + return Either.left(new Error('expiresAt da sessão deve ser posterior a createdAt')) + } + + return Either.right(new UsuarioSessao(attributes)) + } +} diff --git a/src/domain/usuarioSessao/UsuarioSessaoCollection.ts b/src/domain/usuarioSessao/UsuarioSessaoCollection.ts new file mode 100644 index 00000000..38a715db --- /dev/null +++ b/src/domain/usuarioSessao/UsuarioSessaoCollection.ts @@ -0,0 +1,18 @@ +import { type Either } from '@/library/either/Either' + +import { type Attributes } from './UsuarioSessao' + +export interface RotationUpdate { + refreshTokenHash: string + lastUsedAt: Date + expiresAt: Date +} + +export interface UsuarioSessaoCollection { + create(attributes: Attributes): Promise> + findById(id: string): Promise> + findByRefreshTokenHash(hash: string): Promise> + updateRotation(id: string, update: RotationUpdate): Promise> + deleteById(id: string): Promise> + deleteByUsuarioId(usuarioId: number): Promise> +} diff --git a/src/domain/usuarioSessao/error/UserSessionNotFoundError.ts b/src/domain/usuarioSessao/error/UserSessionNotFoundError.ts new file mode 100644 index 00000000..15bc5c9a --- /dev/null +++ b/src/domain/usuarioSessao/error/UserSessionNotFoundError.ts @@ -0,0 +1,10 @@ +import { BaseError } from '@/library/BaseError' + +export class UserSessionNotFoundError extends BaseError { + constructor(params?: { cause?: unknown }) { + super({ + message: 'User session was not found', + cause: params?.cause + }) + } +} diff --git a/src/factory/UsuarioSessaoCollectionFactory.ts b/src/factory/UsuarioSessaoCollectionFactory.ts new file mode 100644 index 00000000..1dbdb273 --- /dev/null +++ b/src/factory/UsuarioSessaoCollectionFactory.ts @@ -0,0 +1,8 @@ +import { UsuarioSessaoCollectionKnexAdapter } from '@/infrastructure/UsuarioSessaoCollectionKnexAdapter' +import { singleton } from '@/library/singleton' + +import { createKnexInstance } from './KnexFactory' + +export const createUsuarioSessaoCollection = singleton(() => { + return new UsuarioSessaoCollectionKnexAdapter({ knex: createKnexInstance() }) +}) diff --git a/src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts b/src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts new file mode 100644 index 00000000..b1a0e628 --- /dev/null +++ b/src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts @@ -0,0 +1,157 @@ +import { Knex } from 'knex' + +import { type Attributes } from '@/domain/usuarioSessao/UsuarioSessao' +import { + type RotationUpdate, + type UsuarioSessaoCollection +} from '@/domain/usuarioSessao/UsuarioSessaoCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +interface Row { + id: string + usuario_id: number + refresh_token_hash: string + created_at: Date + last_used_at: Date + expires_at: Date +} + +const COLUMNS: Array = [ + 'id', + 'usuario_id', + 'refresh_token_hash', + 'created_at', + 'last_used_at', + 'expires_at' +] + +const TABLE = 'usuarios_sessoes' + +export class UsuarioSessaoCollectionKnexAdapter implements UsuarioSessaoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async create(attributes: Attributes): Promise> { + try { + const rows = await this.knex(TABLE) + .insert({ + id: attributes.id, + usuario_id: attributes.usuarioId, + refresh_token_hash: attributes.refreshTokenHash, + created_at: attributes.createdAt, + last_used_at: attributes.lastUsedAt, + expires_at: attributes.expiresAt + }) + .returning(COLUMNS) + + const row = rows[0] + if (!row) { + return Either.left(new CollectionError({ message: 'Failed to create usuário sessão' })) + } + + return Either.right(toAttributes(row)) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to create usuário sessão', + cause: error + })) + } + } + + async findById(id: string): Promise> { + try { + const row = await this.knex(TABLE) + .select(COLUMNS) + .where({ id }) + .first() + + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to find usuário sessão by id', + cause: error + })) + } + } + + async findByRefreshTokenHash(hash: string): Promise> { + try { + const row = await this.knex(TABLE) + .select(COLUMNS) + .where({ refresh_token_hash: hash }) + .first() + + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to find usuário sessão by refresh token hash', + cause: error + })) + } + } + + async updateRotation(id: string, update: RotationUpdate): Promise> { + try { + const rows = await this.knex(TABLE) + .where({ id }) + .update({ + refresh_token_hash: update.refreshTokenHash, + last_used_at: update.lastUsedAt, + expires_at: update.expiresAt + }) + .returning(COLUMNS) + + const row = rows[0] + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to rotate usuário sessão', + cause: error + })) + } + } + + async deleteById(id: string): Promise> { + try { + await this.knex(TABLE).where({ id }).delete() + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to delete usuário sessão', + cause: error + })) + } + } + + async deleteByUsuarioId(usuarioId: number): Promise> { + try { + await this.knex(TABLE).where({ usuario_id: usuarioId }).delete() + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to delete usuário sessões', + cause: error + })) + } + } +} + +function toAttributes(row: Row): Attributes { + return { + id: row.id, + usuarioId: row.usuario_id, + refreshTokenHash: row.refresh_token_hash, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at + } +} diff --git a/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..b9ebadfa --- /dev/null +++ b/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,33 @@ +import { + describe, expect, test +} from 'vitest' + +import { ApagaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessaoUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +describe('ApagaUsuarioSessaoUseCase', () => { + test('deletes the session by id', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession() + await collection.create(session) + + const useCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection: collection }) + const result = await useCase.execute({ id: session.id }) + + expect(result.right()).toBe(true) + expect(collection.rows.has(session.id)).toBe(false) + }) + + test('succeeds when the id is already missing', async () => { + const collection = new FakeUsuarioSessaoCollection() + const useCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection: collection }) + + const result = await useCase.execute({ id: '00000000-0000-4000-8000-000000000000' }) + + expect(result.right()).toBe(true) + }) +}) diff --git a/test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts b/test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts new file mode 100644 index 00000000..4e1ef22f --- /dev/null +++ b/test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts @@ -0,0 +1,37 @@ +import { + describe, expect, test +} from 'vitest' + +import { ApagaUsuarioSessoesUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessoesUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +describe('ApagaUsuarioSessoesUseCase', () => { + test('deletes every session for the usuario', async () => { + const collection = new FakeUsuarioSessaoCollection() + await collection.create(makeSession({ + id: '11111111-1111-4111-8111-111111111111', + usuarioId: 3 + })) + await collection.create(makeSession({ + id: '22222222-2222-4222-8222-222222222222', + usuarioId: 3, + refreshTokenHash: 'e'.repeat(64) + })) + await collection.create(makeSession({ + id: '33333333-3333-4333-8333-333333333333', + usuarioId: 9, + refreshTokenHash: 'f'.repeat(64) + })) + + const useCase = new ApagaUsuarioSessoesUseCase({ usuarioSessaoCollection: collection }) + const result = await useCase.execute({ usuarioId: 3 }) + + expect(result.right()).toBe(true) + expect(collection.rows.size).toBe(1) + expect(collection.rows.has('33333333-3333-4333-8333-333333333333')).toBe(true) + }) +}) diff --git a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts new file mode 100644 index 00000000..0497a4f6 --- /dev/null +++ b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts @@ -0,0 +1,64 @@ +import { + describe, expect, test +} from 'vitest' + +import { BuscaUsuarioSessaoPorHashUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const NOW = new Date('2026-09-27T12:00:00.000Z') +const HASH = 'a'.repeat(64) + +describe('BuscaUsuarioSessaoPorHashUseCase', () => { + test('returns the session when it is still valid', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ + refreshTokenHash: HASH, + expiresAt: new Date('2026-10-01T00:00:00.000Z') + }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ refreshTokenHash: HASH }) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ id: session.id }) + }) + + test('returns null when the hash is missing', async () => { + const useCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshTokenHash: '0'.repeat(64) }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) + + test('deletes an expired session and returns null', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ + refreshTokenHash: HASH, + expiresAt: new Date('2026-09-27T11:59:59.000Z') + }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ refreshTokenHash: HASH }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.rows.has(session.id)).toBe(false) + }) +}) diff --git a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts new file mode 100644 index 00000000..0953fa00 --- /dev/null +++ b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts @@ -0,0 +1,57 @@ +import { + describe, expect, test +} from 'vitest' + +import { BuscaUsuarioSessaoPorIdUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const NOW = new Date('2026-09-27T12:00:00.000Z') + +describe('BuscaUsuarioSessaoPorIdUseCase', () => { + test('returns the session when it is still valid', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ expiresAt: new Date('2026-10-01T00:00:00.000Z') }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ id: session.id }) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ id: session.id }) + }) + + test('returns null when the session is missing', async () => { + const useCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + now: () => NOW + }) + + const result = await useCase.execute({ id: '00000000-0000-4000-8000-000000000000' }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) + + test('deletes an expired session and returns null', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ expiresAt: new Date('2026-09-27T11:59:59.000Z') }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ id: session.id }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.rows.has(session.id)).toBe(false) + }) +}) diff --git a/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..0ceab1f3 --- /dev/null +++ b/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,69 @@ +import { + describe, expect, test +} from 'vitest' + +import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' +import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { RefreshTokenError } from '@/library/auth/error/RefreshTokenError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { FakeUsuarioSessaoCollection } from './FakeUsuarioSessaoCollection' + +const HASH = 'b'.repeat(64) +const TOKEN = 'opaque-refresh' +const NOW = new Date('2026-09-27T12:00:00.000Z') + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: TOKEN, + hash: HASH + }), + hash: token => Either.right(`${token}-hashed`) + } +} + +describe('CriaUsuarioSessaoUseCase', () => { + test('creates a session with generated id, hash, and 30-day expiry', async () => { + const collection = new FakeUsuarioSessaoCollection() + const useCase = new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ usuarioId: 7 }) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value.refreshToken).toBe(TOKEN) + expect(result.value.session.usuarioId).toBe(7) + expect(result.value.session.refreshTokenHash).toBe(HASH) + expect(result.value.session.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ) + expect(result.value.session.createdAt).toEqual(NOW) + expect(result.value.session.lastUsedAt).toEqual(NOW) + expect(result.value.session.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) + expect(collection.rows.get(result.value.session.id)?.refreshTokenHash).toBe(HASH) + }) + + test('propagates refresh generate failure', async () => { + const error = new RefreshTokenError({ message: 'generate failed' }) + const useCase = new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: { + generate: () => Either.left(error), + hash: () => Either.right(HASH) + }, + now: () => NOW + }) + + const result = await useCase.execute({ usuarioId: 1 }) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +}) diff --git a/test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts b/test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts new file mode 100644 index 00000000..6533ac5a --- /dev/null +++ b/test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts @@ -0,0 +1,69 @@ +import { type Attributes, UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { + type RotationUpdate, + type UsuarioSessaoCollection +} from '@/domain/usuarioSessao/UsuarioSessaoCollection' +import { Either } from '@/library/either/Either' + +export class FakeUsuarioSessaoCollection implements UsuarioSessaoCollection { + readonly rows = new Map() + + create(attributes: Attributes): Promise> { + const stored = { ...attributes } + this.rows.set(stored.id, stored) + return Promise.resolve(Either.right({ ...stored })) + } + + findById(id: string): Promise> { + const row = this.rows.get(id) + return Promise.resolve(Either.right(row ? { ...row } : null)) + } + + findByRefreshTokenHash(hash: string): Promise> { + const row = [...this.rows.values()].find(session => session.refreshTokenHash === hash) + return Promise.resolve(Either.right(row ? { ...row } : null)) + } + + updateRotation(id: string, update: RotationUpdate): Promise> { + const current = this.rows.get(id) + if (!current) { + return Promise.resolve(Either.right(null)) + } + + const next: Attributes = { + ...current, + refreshTokenHash: update.refreshTokenHash, + lastUsedAt: update.lastUsedAt, + expiresAt: update.expiresAt + } + this.rows.set(id, next) + return Promise.resolve(Either.right({ ...next })) + } + + deleteById(id: string): Promise> { + this.rows.delete(id) + return Promise.resolve(Either.right(undefined)) + } + + deleteByUsuarioId(usuarioId: number): Promise> { + for (const [id, session] of this.rows) { + if (session.usuarioId === usuarioId) { + this.rows.delete(id) + } + } + return Promise.resolve(Either.right(undefined)) + } +} + +export function makeSession(overrides?: Partial): Attributes { + const createdAt = overrides?.createdAt ?? new Date('2026-01-01T00:00:00.000Z') + return { + id: '11111111-1111-4111-8111-111111111111', + usuarioId: 10, + refreshTokenHash: 'a'.repeat(64), + createdAt, + lastUsedAt: createdAt, + expiresAt: UsuarioSessao.refreshExpiresAt(createdAt), + ...overrides + } +} diff --git a/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..26de48d8 --- /dev/null +++ b/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,100 @@ +import { + describe, expect, test +} from 'vitest' + +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { RotacionaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase' +import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const CURRENT_TOKEN = 'current-refresh' +const CURRENT_HASH = 'c'.repeat(64) +const NEW_TOKEN = 'new-refresh' +const NEW_HASH = 'd'.repeat(64) +const NOW = new Date('2026-09-27T12:00:00.000Z') + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: NEW_TOKEN, + hash: NEW_HASH + }), + hash: token => { + if (token === CURRENT_TOKEN) { + return Either.right(CURRENT_HASH) + } + return Either.right('unknown'.padEnd(64, '0')) + } + } +} + +describe('RotacionaUsuarioSessaoUseCase', () => { + test('updates hash, lastUsedAt, and expiresAt on the same id', async () => { + const collection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + refreshTokenHash: CURRENT_HASH, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2026-10-01T00:00:00.000Z') + }) + await collection.create(existing) + + const useCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value.refreshToken).toBe(NEW_TOKEN) + expect(result.value.session.id).toBe(existing.id) + expect(result.value.session.refreshTokenHash).toBe(NEW_HASH) + expect(result.value.session.lastUsedAt).toEqual(NOW) + expect(result.value.session.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) + expect(collection.rows.get(existing.id)?.refreshTokenHash).toBe(NEW_HASH) + }) + + test('returns not found when the refresh hash is unknown', async () => { + const useCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: 'missing' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) + + test('deletes an expired session and returns not found', async () => { + const collection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + refreshTokenHash: CURRENT_HASH, + expiresAt: new Date('2026-09-27T11:00:00.000Z') + }) + await collection.create(existing) + + const useCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + expect(collection.rows.has(existing.id)).toBe(false) + }) +}) diff --git a/test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts b/test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts new file mode 100644 index 00000000..3e6a728e --- /dev/null +++ b/test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts @@ -0,0 +1,179 @@ +import type { Knex } from 'knex' +import { + describe, expect, test, vi +} from 'vitest' + +import type { Attributes } from '@/domain/usuarioSessao/UsuarioSessao' +import { UsuarioSessaoCollectionKnexAdapter } from '@/infrastructure/UsuarioSessaoCollectionKnexAdapter' + +const COLUMNS = [ + 'id', + 'usuario_id', + 'refresh_token_hash', + 'created_at', + 'last_used_at', + 'expires_at' +] + +const attributes: Attributes = { + id: '11111111-1111-4111-8111-111111111111', + usuarioId: 4, + refreshTokenHash: 'a'.repeat(64), + createdAt: new Date('2026-09-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-09-01T00:00:00.000Z'), + expiresAt: new Date('2026-10-01T00:00:00.000Z') +} + +const row = { + id: attributes.id, + usuario_id: attributes.usuarioId, + refresh_token_hash: attributes.refreshTokenHash, + created_at: attributes.createdAt, + last_used_at: attributes.lastUsedAt, + expires_at: attributes.expiresAt +} + +function stubKnex(promise: Promise): Knex & { builder: Record } { + const builder = {} as Record + builder.insert = vi.fn().mockImplementation(() => builder) + builder.returning = vi.fn().mockImplementation(() => builder) + builder.select = vi.fn().mockImplementation(() => builder) + builder.where = vi.fn().mockImplementation(() => builder) + builder.first = vi.fn().mockImplementation(() => builder) + builder.update = vi.fn().mockImplementation(() => builder) + builder.delete = vi.fn().mockImplementation(() => builder) + builder.then = ( + onResolved: (v: TResult) => unknown, + onRejected?: (e: unknown) => unknown + ): Promise => promise.then(onResolved, onRejected) + + const knex = vi.fn(() => builder) as unknown as Knex & { + builder: Record + } + knex.builder = builder + return knex +} + +describe('UsuarioSessaoCollectionKnexAdapter', () => { + test('create inserts snake_case columns and maps the row', async () => { + const knex = stubKnex(Promise.resolve([row])) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.create(attributes) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(knex).toHaveBeenCalledWith('usuarios_sessoes') + expect(knex.builder.insert).toHaveBeenCalledWith({ + id: attributes.id, + usuario_id: attributes.usuarioId, + refresh_token_hash: attributes.refreshTokenHash, + created_at: attributes.createdAt, + last_used_at: attributes.lastUsedAt, + expires_at: attributes.expiresAt + }) + expect(knex.builder.returning).toHaveBeenCalledWith(COLUMNS) + }) + + test('findById maps a row and returns null when missing', async () => { + const knex = stubKnex(Promise.resolve(row)) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const found = await adapter.findById(attributes.id) + + expect(found.right()).toBe(true) + expect(found.value).toEqual(attributes) + expect(knex.builder.where).toHaveBeenCalledWith({ id: attributes.id }) + expect(knex.builder.select).toHaveBeenCalledWith(COLUMNS) + + const missingKnex = stubKnex(Promise.resolve(undefined)) + const missing = await new UsuarioSessaoCollectionKnexAdapter({ knex: missingKnex }) + .findById(attributes.id) + expect(missing.right()).toBe(true) + expect(missing.value).toBeNull() + }) + + test('findByRefreshTokenHash looks up by hash', async () => { + const knex = stubKnex(Promise.resolve(row)) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.findByRefreshTokenHash(attributes.refreshTokenHash) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(knex.builder.where).toHaveBeenCalledWith({ + refresh_token_hash: attributes.refreshTokenHash + }) + }) + + test('updateRotation updates only rotation columns', async () => { + const updated = { + ...row, + refresh_token_hash: 'b'.repeat(64), + last_used_at: new Date('2026-09-27T00:00:00.000Z'), + expires_at: new Date('2026-10-27T00:00:00.000Z') + } + const knex = stubKnex(Promise.resolve([updated])) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.updateRotation(attributes.id, { + refreshTokenHash: updated.refresh_token_hash, + lastUsedAt: updated.last_used_at, + expiresAt: updated.expires_at + }) + + expect(result.right()).toBe(true) + expect(result.value).toEqual({ + id: attributes.id, + usuarioId: attributes.usuarioId, + refreshTokenHash: updated.refresh_token_hash, + createdAt: attributes.createdAt, + lastUsedAt: updated.last_used_at, + expiresAt: updated.expires_at + }) + expect(knex.builder.where).toHaveBeenCalledWith({ id: attributes.id }) + expect(knex.builder.update).toHaveBeenCalledWith({ + refresh_token_hash: updated.refresh_token_hash, + last_used_at: updated.last_used_at, + expires_at: updated.expires_at + }) + }) + + test('updateRotation returns null when no row is updated', async () => { + const knex = stubKnex(Promise.resolve([])) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.updateRotation(attributes.id, { + refreshTokenHash: 'b'.repeat(64), + lastUsedAt: attributes.lastUsedAt, + expiresAt: attributes.expiresAt + }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) + + test('deleteById and deleteByUsuarioId succeed', async () => { + const knex = stubKnex(Promise.resolve(1)) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const byId = await adapter.deleteById(attributes.id) + expect(byId.right()).toBe(true) + expect(knex.builder.where).toHaveBeenCalledWith({ id: attributes.id }) + expect(knex.builder.delete).toHaveBeenCalled() + + const byUser = await adapter.deleteByUsuarioId(4) + expect(byUser.right()).toBe(true) + expect(knex.builder.where).toHaveBeenCalledWith({ usuario_id: 4 }) + }) + + test('returns Either.left on failure', async () => { + const knex = stubKnex(Promise.reject(new Error('DB down'))) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.create(attributes) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(Error) + }) +})