diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 5f35d7b..45ea5ca 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -1,7 +1,9 @@ import { randomBytes } from "crypto"; import { Request, Response } from "express"; +import { AppError } from "../../lib/errors.js"; import type { OAuthProvider } from "../types/auth.types.js"; import { AuthCallbackParamsSchema } from "../types/auth.types.js"; +import { linkProviderToUser } from "../users/functions/linkProviderToUser.js"; import { AuthService } from "./auth.service.js"; export class AuthController { @@ -12,6 +14,13 @@ export class AuthController { const state = randomBytes(16).toString("hex"); (req.session as { oauth_state?: string }).oauth_state = state; + + if (req.query.intent === "link" && req.session.userId) { + (req.session as { oauth_intent?: string }).oauth_intent = "link"; + } else { + delete (req.session as { oauth_intent?: string }).oauth_intent; + } + await req.session.save(); const url = await this.authService.getAuthUrl(provider, state); @@ -43,6 +52,33 @@ export class AuthController { delete (req.session as { oauth_state?: string }).oauth_state; + const intent = (req.session as { oauth_intent?: string }).oauth_intent; + delete (req.session as { oauth_intent?: string }).oauth_intent; + + if (intent === "link" && req.session.userId) { + try { + const profile = await this.authService.getProfileFromProvider({ + ...params, + callbackUrl, + }); + await linkProviderToUser({ + userId: req.session.userId, + provider: params.provider, + profile, + }); + await req.session.save(); + return res.redirect( + `${frontendUrl}/perfil?linked=${params.provider}`, + ); + } catch (linkError) { + const code = + linkError instanceof AppError && linkError.code === "CONFLICT" + ? "provider_already_linked" + : "link_failed"; + return res.redirect(`${frontendUrl}/perfil?error=${code}`); + } + } + const result = await this.authService.handleCallback({ ...params, callbackUrl, diff --git a/backend/src/modules/auth/connections.controller.ts b/backend/src/modules/auth/connections.controller.ts new file mode 100644 index 0000000..df3cd74 --- /dev/null +++ b/backend/src/modules/auth/connections.controller.ts @@ -0,0 +1,28 @@ +// backend/src/modules/auth/connections.controller.ts +import { Request, Response } from "express"; +import { AppError } from "../../lib/errors"; +import { disconnectProvider } from "../users/functions/disconnectProvider"; +import { + listUserConnections, + SUPPORTED_PROVIDERS, +} from "../users/functions/listUserConnections"; + +export class ConnectionsController { + async list(req: Request, res: Response) { + const userId = req.session.userId as string; + const result = await listUserConnections(userId); + return res.json(result); + } + + async disconnect(req: Request, res: Response) { + const userId = req.session.userId as string; + const provider = req.params.provider; + + if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { + throw AppError.validation("Provider inválido."); + } + + await disconnectProvider({ userId, provider }); + return res.json({ ok: true }); + } +} diff --git a/backend/src/modules/users/functions/disconnectProvider.ts b/backend/src/modules/users/functions/disconnectProvider.ts new file mode 100644 index 0000000..24edf53 --- /dev/null +++ b/backend/src/modules/users/functions/disconnectProvider.ts @@ -0,0 +1,48 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../../../db/client"; +import { accounts } from "../../../db/schema"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; + +type DisconnectParams = { userId: string; provider: string }; + +async function runDisconnect( + { userId, provider }: DisconnectParams, + tx: DB, +): Promise { + const userAccounts = await tx + .select() + .from(accounts) + .where(eq(accounts.userId, userId)) + .for("update"); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const remainingProviders = new Set( + userAccounts.map((a) => a.provider).filter((p) => p !== provider), + ); + + const methodsAfter = remainingProviders.size + (hasPassword ? 1 : 0); + + if (methodsAfter === 0) { + throw AppError.conflict( + "Não é possível desconectar seu último método de login.", + ); + } + + await tx + .delete(accounts) + .where(and(eq(accounts.userId, userId), eq(accounts.provider, provider))); +} + +export async function disconnectProvider( + params: DisconnectParams, + tx?: DB, +): Promise { + if (tx) return runDisconnect(params, tx); + return db.transaction((t) => runDisconnect(params, t as unknown as DB)); +} diff --git a/backend/src/modules/users/functions/linkProviderToUser.ts b/backend/src/modules/users/functions/linkProviderToUser.ts new file mode 100644 index 0000000..6524faf --- /dev/null +++ b/backend/src/modules/users/functions/linkProviderToUser.ts @@ -0,0 +1,28 @@ +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; +import { OAuthProfile } from "../../types/auth.types"; +import { createAccount } from "./createAccount"; + +type LinkProviderParams = { + userId: string; + provider: string; + profile: OAuthProfile; +}; + +export async function linkProviderToUser( + { userId, provider, profile }: LinkProviderParams, + tx: DB = db, +): Promise { + const existingAccount = await tx.query.accounts.findFirst({ + where: (acc, { eq, and }) => + and(eq(acc.provider, provider), eq(acc.providerAccountId, profile.id)), + }); + + if (existingAccount) { + if (existingAccount.userId === userId) return; + throw AppError.conflict("Essa conta já está vinculada a outro usuário."); + } + + await createAccount({ userId, provider, profile }, tx); +} diff --git a/backend/src/modules/users/functions/listUserConnections.ts b/backend/src/modules/users/functions/listUserConnections.ts new file mode 100644 index 0000000..573bda5 --- /dev/null +++ b/backend/src/modules/users/functions/listUserConnections.ts @@ -0,0 +1,42 @@ +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; + +export const SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const; +export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: Date | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +export async function listUserConnections( + userId: string, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const connections: ConnectionStatus[] = SUPPORTED_PROVIDERS.map((provider) => { + const account = userAccounts.find((a) => a.provider === provider); + return { + provider, + connected: Boolean(account), + connectedAt: account?.createdAt ?? null, + }; + }); + + return { hasPassword, connections }; +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 10cf4c6..5fabeec 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -4,9 +4,11 @@ import { authAccountRateLimiter, authIpRateLimiter, } from "../middleware/rateLimit"; +import { requireAuth } from "../middleware/requireAuth"; import { validate } from "../middleware/validate"; import { AuthController } from "../modules/auth/auth.controller"; import { AuthService } from "../modules/auth/auth.service"; +import { ConnectionsController } from "../modules/auth/connections.controller"; import { CredentialsController } from "../modules/auth/credentials.controller"; import { CredentialsService } from "../modules/auth/credentials.service"; import { OAuthProviderSchema } from "../modules/types/auth.types"; @@ -21,6 +23,7 @@ const authService = new AuthService(); const authController = new AuthController(authService); const credentialsService = new CredentialsService(); const credentialsController = new CredentialsController(credentialsService); +const connectionsController = new ConnectionsController(); const providerParamsSchema = z.object({ provider: OAuthProviderSchema, @@ -38,6 +41,14 @@ router.get("/:provider/callback", (req, res, next) => { authController.callback(req, res).catch(next); }); +// Connections (usuário logado) +router.get("/connections", requireAuth, (req, res, next) => { + connectionsController.list(req, res).catch(next); +}); +router.delete("/connections/:provider", requireAuth, (req, res, next) => { + connectionsController.disconnect(req, res).catch(next); +}); + // Credentials router.post( "/register", diff --git a/backend/tests/integration/routes/connections.routes.test.ts b/backend/tests/integration/routes/connections.routes.test.ts new file mode 100644 index 0000000..6347ae4 --- /dev/null +++ b/backend/tests/integration/routes/connections.routes.test.ts @@ -0,0 +1,87 @@ +// backend/tests/integration/routes/connections.routes.test.ts +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + listUserConnections: vi.fn(), + disconnectProvider: vi.fn(), +})); + +vi.mock("../../../src/modules/users/functions/listUserConnections", () => ({ + listUserConnections: mocks.listUserConnections, + SUPPORTED_PROVIDERS: ["google", "linkedin", "github"], +})); + +vi.mock("../../../src/modules/users/functions/disconnectProvider", () => ({ + disconnectProvider: mocks.disconnectProvider, +})); + +// Sessão fake: injeta userId +vi.mock("../../../src/middleware/requireAuth", () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.session = { userId: "user-A" }; + next(); + }, +})); + +import { errorHandler } from "../../../src/middleware/errorHandler"; +import { ConnectionsController } from "../../../src/modules/auth/connections.controller"; +import { requireAuth } from "../../../src/middleware/requireAuth"; + +function buildApp() { + const app = express(); + app.use(express.json()); + const controller = new ConnectionsController(); + app.get("/auth/connections", requireAuth, (req, res, next) => + controller.list(req, res).catch(next), + ); + app.delete("/auth/connections/:provider", requireAuth, (req, res, next) => + controller.disconnect(req, res).catch(next), + ); + app.use(errorHandler); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("connections routes", () => { + it("GET /auth/connections retorna status", async () => { + mocks.listUserConnections.mockResolvedValue({ + hasPassword: false, + connections: [{ provider: "google", connected: true, connectedAt: null }], + }); + const res = await request(buildApp()).get("/auth/connections"); + expect(res.status).toBe(200); + expect(res.body.connections[0].provider).toBe("google"); + }); + + it("DELETE /auth/connections/:provider retorna 200", async () => { + mocks.disconnectProvider.mockResolvedValue(undefined); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(200); + expect(mocks.disconnectProvider).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + }); + }); + + it("DELETE responde 409 no último método", async () => { + mocks.disconnectProvider.mockRejectedValue( + AppError.conflict("Não é possível desconectar seu último método de login."), + ); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("CONFLICT"); + }); + + it("DELETE responde 400 para provider não suportado", async () => { + const res = await request(buildApp()).delete("/auth/connections/facebook"); + expect(res.status).toBe(400); + expect(res.body.code).toBe("VALIDATION_ERROR"); + expect(mocks.disconnectProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/auth/auth.controller.link.test.ts b/backend/tests/unit/modules/auth/auth.controller.link.test.ts new file mode 100644 index 0000000..7d09a7a --- /dev/null +++ b/backend/tests/unit/modules/auth/auth.controller.link.test.ts @@ -0,0 +1,98 @@ +// backend/tests/unit/modules/auth/auth.controller.link.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + linkProviderToUser: vi.fn(), + getProfileFromProvider: vi.fn(), + handleCallback: vi.fn(), +})); + +vi.mock("../../../../src/modules/users/functions/linkProviderToUser", () => ({ + linkProviderToUser: mocks.linkProviderToUser, +})); + +import { AuthController } from "../../../../src/modules/auth/auth.controller"; + +function fakeRes() { + return { + redirect: vi.fn(), + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } as any; +} + +function fakeService() { + return { + getProfileFromProvider: mocks.getProfileFromProvider, + handleCallback: mocks.handleCallback, + getAuthUrl: vi.fn(), + } as any; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.FRONTEND_URL = "http://localhost:5173"; + process.env.APP_URL = "http://localhost:3001"; +}); + +describe("AuthController callback — modo vínculo", () => { + it("vincula e redireciona para /perfil?linked= sem trocar sessão", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockResolvedValue(undefined); + + const controller = new AuthController(fakeService()); + const save = vi.fn(); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save, + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(mocks.linkProviderToUser).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + profile: { id: "prov-1" }, + }); + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil?linked=google", + ); + expect(mocks.handleCallback).not.toHaveBeenCalled(); + }); + + it("redireciona com erro quando o provider já está vinculado", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockRejectedValue( + AppError.conflict("já vinculado"), + ); + + const controller = new AuthController(fakeService()); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save: vi.fn(), + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil?error=provider_already_linked", + ); + }); +}); diff --git a/backend/tests/unit/modules/users/disconnectProvider.test.ts b/backend/tests/unit/modules/users/disconnectProvider.test.ts new file mode 100644 index 0000000..9bb10d1 --- /dev/null +++ b/backend/tests/unit/modules/users/disconnectProvider.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + selectForUpdate: vi.fn(), + credentialsFindFirst: vi.fn(), + deleteWhere: vi.fn(), +})); + +// mockTx mirrors what runDisconnect uses inside the transaction: +// tx.select().from().where().for("update") → resolves to accounts array +// tx.query.credentials.findFirst → resolves to credential or undefined +// tx.delete().where() → called to delete the account row +const mockTx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + for: mocks.selectForUpdate, + })), + })), + })), + query: { + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + delete: vi.fn(() => ({ where: mocks.deleteWhere })), +}; + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + transaction: (cb: (tx: typeof mockTx) => Promise) => cb(mockTx), + }, +})); + +vi.mock("../../../../src/db/schema/index.js", () => ({ accounts: {} })); + +import { disconnectProvider } from "../../../../src/modules/users/functions/disconnectProvider"; + +beforeEach(() => { + vi.clearAllMocks(); + // Re-wire the chainable select mock after clearAllMocks resets call counts + // (the implementations are already defined above — just restore them) + mockTx.select.mockReturnValue({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + for: mocks.selectForUpdate, + })), + })), + }); + mockTx.delete.mockReturnValue({ where: mocks.deleteWhere }); +}); + +describe("disconnectProvider", () => { + it("desconecta quando há outro método (outro provider)", async () => { + mocks.selectForUpdate.mockResolvedValue([ + { provider: "google" }, + { provider: "github" }, + ]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("desconecta quando há senha como fallback", async () => { + mocks.selectForUpdate.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("bloqueia quando é o último método", async () => { + mocks.selectForUpdate.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await expect( + disconnectProvider({ userId: "user-A", provider: "google" }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.deleteWhere).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/users/linkProviderToUser.test.ts b/backend/tests/unit/modules/users/linkProviderToUser.test.ts new file mode 100644 index 0000000..069cab6 --- /dev/null +++ b/backend/tests/unit/modules/users/linkProviderToUser.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindFirst: vi.fn(), + createAccount: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { query: { accounts: { findFirst: mocks.accountsFindFirst } } }, +})); + +vi.mock("../../../../src/modules/users/functions/createAccount.js", () => ({ + createAccount: mocks.createAccount, +})); + +import { linkProviderToUser } from "../../../../src/modules/users/functions/linkProviderToUser"; + +const profile = { id: "prov-123", email: "a@a.com" } as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("linkProviderToUser", () => { + it("cria account quando provider não está vinculado", async () => { + mocks.accountsFindFirst.mockResolvedValue(undefined); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).toHaveBeenCalledWith( + { userId: "user-A", provider: "google", profile }, + expect.anything(), + ); + }); + + it("é idempotente quando já vinculado ao mesmo usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-A" }); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); + + it("lança conflito quando vinculado a outro usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-B" }); + await expect( + linkProviderToUser({ userId: "user-A", provider: "google", profile }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/users/listUserConnections.test.ts b/backend/tests/unit/modules/users/listUserConnections.test.ts new file mode 100644 index 0000000..685e460 --- /dev/null +++ b/backend/tests/unit/modules/users/listUserConnections.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + }, +})); + +import { listUserConnections } from "../../../../src/modules/users/functions/listUserConnections"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listUserConnections", () => { + it("marca conectados e retorna hasPassword", async () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google", createdAt }, + ]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + + const result = await listUserConnections("user-A"); + + expect(result.hasPassword).toBe(true); + expect(result.connections).toEqual([ + { provider: "google", connected: true, connectedAt: createdAt }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ]); + }); +}); diff --git a/docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md b/docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md new file mode 100644 index 0000000..baf2d19 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md @@ -0,0 +1,1103 @@ +# PAV-7 — Gerenciar Conexões Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permitir que um usuário logado conecte/desconecte múltiplos providers OAuth (Google/LinkedIn/GitHub) à sua conta, com tela de gestão e validações. + +**Architecture:** Reusa o fluxo OAuth existente com uma flag `intent=link` na sessão (Abordagem A do spec) para vincular providers sem novos redirect URIs. Novas funções puras na camada `users/functions` fazem link/disconnect/list; um `ConnectionsController` expõe list/disconnect; o `AuthController` ganha um branch de "modo vínculo" no callback. Frontend ganha uma `ConnectionsPage` em `/perfil/conexoes`. + +**Tech Stack:** Node + Express + Drizzle ORM (Postgres), Vitest + Supertest, React + React Router + Vite. + +## Global Constraints + +- Sem novas migrations / mudança de schema. `accounts` e `credentials` já existem. +- Providers suportados (verbatim): `["google", "linkedin", "github"]`. +- Regra de desconexão: manter **≥ 1 método de login** (provider vinculado OU senha em `credentials`). +- Conflito ao vincular provider já usado por outro usuário → `AppError.conflict` (HTTP 409). +- Erros de domínio usam `AppError` (`src/lib/errors.ts`); `errorHandler` já mapeia `AppError` → status. +- Testes: `vitest run` (backend). Mocks via `vi.hoisted` seguindo `tests/unit/modules/auth/providers.test.ts`. +- Commits frequentes, um por task. + +**Spec:** `docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md` + +--- + +## File Structure + +**Backend (criar):** +- `src/modules/users/functions/linkProviderToUser.ts` — vincula provider ao usuário, com checagem de conflito. +- `src/modules/users/functions/disconnectProvider.ts` — remove provider, valida último método. +- `src/modules/users/functions/listUserConnections.ts` — lista status dos providers + `hasPassword`; exporta `SUPPORTED_PROVIDERS`. +- `src/modules/auth/connections.controller.ts` — `ConnectionsController` (list, disconnect). + +**Backend (modificar):** +- `src/modules/auth/auth.controller.ts` — `getUrl` grava `oauth_intent`; `callback` ganha branch de vínculo. +- `src/routes/auth.routes.ts` — rotas `GET /auth/connections`, `DELETE /auth/connections/:provider`. + +**Frontend (criar):** +- `src/domains/auth/infrastructure/connectionsApi.ts` — client HTTP (get/disconnect/connect). +- `src/domains/auth/presentation/pages/ConnectionsPage.tsx` — tela "Gerenciar conexões". + +**Frontend (modificar):** +- `src/app/AppRoutes.tsx` — rota `/perfil/conexoes`. + +--- + +## Task 1: `linkProviderToUser` (vincular com checagem de conflito) + +**Files:** +- Create: `backend/src/modules/users/functions/linkProviderToUser.ts` +- Test: `backend/tests/unit/modules/users/linkProviderToUser.test.ts` + +**Interfaces:** +- Consumes: `createAccount({ userId, provider, profile }, tx)` de `./createAccount`; `AppError` de `src/lib/errors`. +- Produces: `linkProviderToUser({ userId: string, provider: string, profile: OAuthProfile }, tx?: DB): Promise` — no-op se já vinculado ao mesmo usuário; lança `AppError.conflict` se vinculado a outro; senão cria account. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/users/linkProviderToUser.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindFirst: vi.fn(), + createAccount: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { query: { accounts: { findFirst: mocks.accountsFindFirst } } }, +})); + +vi.mock("../../../../src/modules/users/functions/createAccount.js", () => ({ + createAccount: mocks.createAccount, +})); + +import { linkProviderToUser } from "../../../../src/modules/users/functions/linkProviderToUser"; + +const profile = { id: "prov-123", email: "a@a.com" } as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("linkProviderToUser", () => { + it("cria account quando provider não está vinculado", async () => { + mocks.accountsFindFirst.mockResolvedValue(undefined); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).toHaveBeenCalledWith( + { userId: "user-A", provider: "google", profile }, + expect.anything(), + ); + }); + + it("é idempotente quando já vinculado ao mesmo usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-A" }); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); + + it("lança conflito quando vinculado a outro usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-B" }); + await expect( + linkProviderToUser({ userId: "user-A", provider: "google", profile }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/users/linkProviderToUser.test.ts` +Expected: FAIL (module `linkProviderToUser` não existe). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// backend/src/modules/users/functions/linkProviderToUser.ts +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; +import { OAuthProfile } from "../../types/auth.types"; +import { createAccount } from "./createAccount"; + +type LinkProviderParams = { + userId: string; + provider: string; + profile: OAuthProfile; +}; + +export async function linkProviderToUser( + { userId, provider, profile }: LinkProviderParams, + tx: DB = db, +): Promise { + const existingAccount = await tx.query.accounts.findFirst({ + where: (acc, { eq, and }) => + and(eq(acc.provider, provider), eq(acc.providerAccountId, profile.id)), + }); + + if (existingAccount) { + if (existingAccount.userId === userId) return; + throw AppError.conflict("Essa conta já está vinculada a outro usuário."); + } + + await createAccount({ userId, provider, profile }, tx); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run tests/unit/modules/users/linkProviderToUser.test.ts` +Expected: PASS (3 testes). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/modules/users/functions/linkProviderToUser.ts backend/tests/unit/modules/users/linkProviderToUser.test.ts +git commit -m "feat(PAV-7): vincular provider a usuário com checagem de conflito" +``` + +--- + +## Task 2: `disconnectProvider` (desconectar com validação de último método) + +**Files:** +- Create: `backend/src/modules/users/functions/disconnectProvider.ts` +- Test: `backend/tests/unit/modules/users/disconnectProvider.test.ts` + +**Interfaces:** +- Consumes: `db` (`accounts`, `credentials` queries), `accounts` schema, `AppError`, `and`/`eq` de `drizzle-orm`. +- Produces: `disconnectProvider({ userId: string, provider: string }, tx?: DB): Promise` — lança `AppError.conflict` se remover deixaria 0 métodos; senão deleta rows de `accounts` desse `(userId, provider)`. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/users/disconnectProvider.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + delete: vi.fn(() => ({ where: mocks.deleteWhere })), + }, +})); + +vi.mock("../../../../src/db/schema/index.js", () => ({ accounts: {} })); + +import { disconnectProvider } from "../../../../src/modules/users/functions/disconnectProvider"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("disconnectProvider", () => { + it("desconecta quando há outro método (outro provider)", async () => { + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google" }, + { provider: "github" }, + ]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("desconecta quando há senha como fallback", async () => { + mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("bloqueia quando é o último método", async () => { + mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await expect( + disconnectProvider({ userId: "user-A", provider: "google" }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.deleteWhere).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/users/disconnectProvider.test.ts` +Expected: FAIL (módulo não existe). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// backend/src/modules/users/functions/disconnectProvider.ts +import { and, eq } from "drizzle-orm"; +import { db } from "../../../db/client"; +import { accounts } from "../../../db/schema"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; + +type DisconnectParams = { userId: string; provider: string }; + +export async function disconnectProvider( + { userId, provider }: DisconnectParams, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const remainingProviders = new Set( + userAccounts.map((a) => a.provider).filter((p) => p !== provider), + ); + + const methodsAfter = remainingProviders.size + (hasPassword ? 1 : 0); + + if (methodsAfter === 0) { + throw AppError.conflict( + "Não é possível desconectar seu último método de login.", + ); + } + + await tx + .delete(accounts) + .where(and(eq(accounts.userId, userId), eq(accounts.provider, provider))); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run tests/unit/modules/users/disconnectProvider.test.ts` +Expected: PASS (3 testes). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/modules/users/functions/disconnectProvider.ts backend/tests/unit/modules/users/disconnectProvider.test.ts +git commit -m "feat(PAV-7): desconectar provider validando último método de login" +``` + +--- + +## Task 3: `listUserConnections` (status dos providers + hasPassword) + +**Files:** +- Create: `backend/src/modules/users/functions/listUserConnections.ts` +- Test: `backend/tests/unit/modules/users/listUserConnections.test.ts` + +**Interfaces:** +- Consumes: `db` (`accounts`, `credentials` queries). +- Produces: + - `SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const` + - `type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]` + - `type ConnectionStatus = { provider: SupportedProvider; connected: boolean; connectedAt: Date | null }` + - `type UserConnections = { hasPassword: boolean; connections: ConnectionStatus[] }` + - `listUserConnections(userId: string, tx?: DB): Promise` + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/users/listUserConnections.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + }, +})); + +import { listUserConnections } from "../../../../src/modules/users/functions/listUserConnections"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listUserConnections", () => { + it("marca conectados e retorna hasPassword", async () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google", createdAt }, + ]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + + const result = await listUserConnections("user-A"); + + expect(result.hasPassword).toBe(true); + expect(result.connections).toEqual([ + { provider: "google", connected: true, connectedAt: createdAt }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/users/listUserConnections.test.ts` +Expected: FAIL (módulo não existe). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// backend/src/modules/users/functions/listUserConnections.ts +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; + +export const SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const; +export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: Date | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +export async function listUserConnections( + userId: string, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const connections: ConnectionStatus[] = SUPPORTED_PROVIDERS.map((provider) => { + const account = userAccounts.find((a) => a.provider === provider); + return { + provider, + connected: Boolean(account), + connectedAt: account?.createdAt ?? null, + }; + }); + + return { hasPassword, connections }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run tests/unit/modules/users/listUserConnections.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/modules/users/functions/listUserConnections.ts backend/tests/unit/modules/users/listUserConnections.test.ts +git commit -m "feat(PAV-7): listar conexões do usuário com status por provider" +``` + +--- + +## Task 4: `ConnectionsController` + rotas `GET/DELETE /auth/connections` + +**Files:** +- Create: `backend/src/modules/auth/connections.controller.ts` +- Modify: `backend/src/routes/auth.routes.ts` +- Test: `backend/tests/integration/routes/connections.routes.test.ts` + +**Interfaces:** +- Consumes: `listUserConnections`, `disconnectProvider`, `SUPPORTED_PROVIDERS`, `requireAuth`, `AppError`. +- Produces: + - `class ConnectionsController { list(req, res): Promise; disconnect(req, res): Promise }` + - Rotas: `GET /auth/connections` (requireAuth) → `{ hasPassword, connections }`; `DELETE /auth/connections/:provider` (requireAuth) → `{ ok: true }`. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/integration/routes/connections.routes.test.ts +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + listUserConnections: vi.fn(), + disconnectProvider: vi.fn(), +})); + +vi.mock("../../../src/modules/users/functions/listUserConnections", () => ({ + listUserConnections: mocks.listUserConnections, + SUPPORTED_PROVIDERS: ["google", "linkedin", "github"], +})); + +vi.mock("../../../src/modules/users/functions/disconnectProvider", () => ({ + disconnectProvider: mocks.disconnectProvider, +})); + +// Sessão fake: injeta userId +vi.mock("../../../src/middleware/requireAuth", () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.session = { userId: "user-A" }; + next(); + }, +})); + +import { errorHandler } from "../../../src/middleware/errorHandler"; +import { ConnectionsController } from "../../../src/modules/auth/connections.controller"; +import { requireAuth } from "../../../src/middleware/requireAuth"; + +function buildApp() { + const app = express(); + app.use(express.json()); + const controller = new ConnectionsController(); + app.get("/auth/connections", requireAuth, (req, res, next) => + controller.list(req, res).catch(next), + ); + app.delete("/auth/connections/:provider", requireAuth, (req, res, next) => + controller.disconnect(req, res).catch(next), + ); + app.use(errorHandler); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("connections routes", () => { + it("GET /auth/connections retorna status", async () => { + mocks.listUserConnections.mockResolvedValue({ + hasPassword: false, + connections: [{ provider: "google", connected: true, connectedAt: null }], + }); + const res = await request(buildApp()).get("/auth/connections"); + expect(res.status).toBe(200); + expect(res.body.connections[0].provider).toBe("google"); + }); + + it("DELETE /auth/connections/:provider retorna 200", async () => { + mocks.disconnectProvider.mockResolvedValue(undefined); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(200); + expect(mocks.disconnectProvider).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + }); + }); + + it("DELETE responde 409 no último método", async () => { + mocks.disconnectProvider.mockRejectedValue( + AppError.conflict("Não é possível desconectar seu último método de login."), + ); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("CONFLICT"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/integration/routes/connections.routes.test.ts` +Expected: FAIL (`ConnectionsController` não existe). + +- [ ] **Step 3: Write the controller** + +```ts +// backend/src/modules/auth/connections.controller.ts +import { Request, Response } from "express"; +import { AppError } from "../../lib/errors"; +import { disconnectProvider } from "../users/functions/disconnectProvider"; +import { + listUserConnections, + SUPPORTED_PROVIDERS, +} from "../users/functions/listUserConnections"; + +export class ConnectionsController { + async list(req: Request, res: Response) { + const userId = req.session.userId as string; + const result = await listUserConnections(userId); + return res.json(result); + } + + async disconnect(req: Request, res: Response) { + const userId = req.session.userId as string; + const provider = req.params.provider; + + if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { + throw AppError.validation("Provider inválido."); + } + + await disconnectProvider({ userId, provider }); + return res.json({ ok: true }); + } +} +``` + +- [ ] **Step 4: Wire the routes** + +Em `backend/src/routes/auth.routes.ts`, adicione o import e as rotas logo após o bloco `// OAuth` (antes de `// Credentials`): + +```ts +import { requireAuth } from "../middleware/requireAuth"; +import { ConnectionsController } from "../modules/auth/connections.controller"; + +const connectionsController = new ConnectionsController(); + +// Connections (usuário logado) +router.get("/connections", requireAuth, (req, res, next) => { + connectionsController.list(req, res).catch(next); +}); +router.delete("/connections/:provider", requireAuth, (req, res, next) => { + connectionsController.disconnect(req, res).catch(next); +}); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && npx vitest run tests/integration/routes/connections.routes.test.ts` +Expected: PASS (3 testes). + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/modules/auth/connections.controller.ts backend/src/routes/auth.routes.ts backend/tests/integration/routes/connections.routes.test.ts +git commit -m "feat(PAV-7): endpoints GET/DELETE de conexões do usuário" +``` + +--- + +## Task 5: Modo vínculo no `AuthController` (getUrl intent + callback branch) + +**Files:** +- Modify: `backend/src/modules/auth/auth.controller.ts` +- Test: `backend/tests/unit/modules/auth/auth.controller.link.test.ts` + +**Interfaces:** +- Consumes: `linkProviderToUser` (Task 1), `AuthService.getProfileFromProvider`, `AppError`. +- Produces: `getUrl` grava `oauth_intent="link"` na sessão quando `?intent=link` e há `userId`; `callback` vincula e redireciona para `${FRONTEND_URL}/perfil/conexoes?linked=` (sucesso) ou `?error=provider_already_linked|link_failed` (falha), sem trocar a sessão. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/auth/auth.controller.link.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + linkProviderToUser: vi.fn(), + getProfileFromProvider: vi.fn(), + handleCallback: vi.fn(), +})); + +vi.mock("../../../../src/modules/users/functions/linkProviderToUser", () => ({ + linkProviderToUser: mocks.linkProviderToUser, +})); + +import { AuthController } from "../../../../src/modules/auth/auth.controller"; + +function fakeRes() { + return { + redirect: vi.fn(), + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } as any; +} + +function fakeService() { + return { + getProfileFromProvider: mocks.getProfileFromProvider, + handleCallback: mocks.handleCallback, + getAuthUrl: vi.fn(), + } as any; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.FRONTEND_URL = "http://localhost:5173"; + process.env.APP_URL = "http://localhost:3001"; +}); + +describe("AuthController callback — modo vínculo", () => { + it("vincula e redireciona para /perfil/conexoes?linked= sem trocar sessão", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockResolvedValue(undefined); + + const controller = new AuthController(fakeService()); + const save = vi.fn(); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save, + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(mocks.linkProviderToUser).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + profile: { id: "prov-1" }, + }); + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil/conexoes?linked=google", + ); + expect(mocks.handleCallback).not.toHaveBeenCalled(); + }); + + it("redireciona com erro quando o provider já está vinculado", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockRejectedValue( + AppError.conflict("já vinculado"), + ); + + const controller = new AuthController(fakeService()); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save: vi.fn(), + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil/conexoes?error=provider_already_linked", + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/auth/auth.controller.link.test.ts` +Expected: FAIL (callback ainda não faz branch de vínculo). + +- [ ] **Step 3: Update `getUrl` para gravar a intenção** + +Em `backend/src/modules/auth/auth.controller.ts`, substitua o corpo de `getUrl` por: + +```ts + async getUrl(req: Request, res: Response) { + const provider = req.params.provider as OAuthProvider; + const state = randomBytes(16).toString("hex"); + + (req.session as { oauth_state?: string }).oauth_state = state; + + if (req.query.intent === "link" && req.session.userId) { + (req.session as { oauth_intent?: string }).oauth_intent = "link"; + } else { + delete (req.session as { oauth_intent?: string }).oauth_intent; + } + + await req.session.save(); + + const url = await this.authService.getAuthUrl(provider, state); + return res.json({ url }); + } +``` + +- [ ] **Step 4: Add the link branch in `callback`** + +No `callback`, logo após a linha `delete (req.session as { oauth_state?: string }).oauth_state;` e ANTES de `const result = await this.authService.handleCallback(...)`, insira: + +```ts + const intent = (req.session as { oauth_intent?: string }).oauth_intent; + delete (req.session as { oauth_intent?: string }).oauth_intent; + + if (intent === "link" && req.session.userId) { + try { + const profile = await this.authService.getProfileFromProvider({ + ...params, + callbackUrl, + }); + await linkProviderToUser({ + userId: req.session.userId, + provider: params.provider, + profile, + }); + await req.session.save(); + return res.redirect( + `${frontendUrl}/perfil/conexoes?linked=${params.provider}`, + ); + } catch (linkError) { + const code = + linkError instanceof AppError + ? "provider_already_linked" + : "link_failed"; + return res.redirect(`${frontendUrl}/perfil/conexoes?error=${code}`); + } + } +``` + +Adicione os imports no topo do arquivo: + +```ts +import { AppError } from "../../lib/errors.js"; +import { linkProviderToUser } from "../users/functions/linkProviderToUser.js"; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && npx vitest run tests/unit/modules/auth/auth.controller.link.test.ts` +Expected: PASS (2 testes). + +- [ ] **Step 6: Run the full auth suite (regressão)** + +Run: `cd backend && npx vitest run tests/unit/modules/auth tests/integration/routes/auth.routes.test.ts` +Expected: PASS (o fluxo de login existente continua funcionando). + +- [ ] **Step 7: Commit** + +```bash +git add backend/src/modules/auth/auth.controller.ts backend/tests/unit/modules/auth/auth.controller.link.test.ts +git commit -m "feat(PAV-7): modo vínculo no callback OAuth (conectar provider logado)" +``` + +--- + +## Task 6: Frontend — `connectionsApi.ts` + +**Files:** +- Create: `frontend/src/domains/auth/infrastructure/connectionsApi.ts` + +**Interfaces:** +- Produces: + - `type SupportedProvider = "google" | "linkedin" | "github"` + - `type ConnectionStatus = { provider: SupportedProvider; connected: boolean; connectedAt: string | null }` + - `type UserConnections = { hasPassword: boolean; connections: ConnectionStatus[] }` + - `getConnections(): Promise` + - `disconnectProvider(provider: SupportedProvider): Promise` + - `connectProvider(provider: SupportedProvider): Promise` (redireciona o browser) + +- [ ] **Step 1: Write the module** + +Segue o padrão de `frontend/src/domains/auth/infrastructure/authApi.ts` (base via `VITE_API_BASE_URL`, `credentials: "include"`). + +```ts +// frontend/src/domains/auth/infrastructure/connectionsApi.ts +export type SupportedProvider = "google" | "linkedin" | "github"; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: string | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +function getBaseUrl(): string { + const base = import.meta.env.VITE_API_BASE_URL; + if (base && base.trim().length > 0) return base.replace(/\/+$/, ""); + return ""; +} + +function buildUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + const base = getBaseUrl(); + return base ? `${base}${normalized}` : normalized; +} + +export async function getConnections(): Promise { + const response = await fetch(buildUrl("/auth/connections"), { + credentials: "include", + }); + if (!response.ok) throw new Error("Falha ao carregar conexões."); + return (await response.json()) as UserConnections; +} + +export async function disconnectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch(buildUrl(`/auth/connections/${provider}`), { + method: "DELETE", + credentials: "include", + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + message?: string; + }; + throw new Error(payload.message ?? "Falha ao desconectar."); + } +} + +export async function connectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch( + buildUrl(`/auth/${provider}/url?intent=link`), + { credentials: "include" }, + ); + if (!response.ok) throw new Error("Falha ao iniciar conexão."); + const { url } = (await response.json()) as { url: string }; + window.location.href = url; +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd frontend && npx tsc --noEmit` +Expected: sem erros novos no arquivo criado. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/domains/auth/infrastructure/connectionsApi.ts +git commit -m "feat(PAV-7): client de API de conexões (frontend)" +``` + +--- + +## Task 7: Frontend — `ConnectionsPage` + rota `/perfil/conexoes` + +**Files:** +- Create: `frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx` +- Modify: `frontend/src/app/AppRoutes.tsx` + +**Interfaces:** +- Consumes: `getConnections`, `disconnectProvider`, `connectProvider`, tipos de `connectionsApi`; `NewDashboardLayout`, `ProtectedRoute` (em `AppRoutes`). +- Produces: componente default `ConnectionsPage`; rota protegida `/perfil/conexoes`. + +- [ ] **Step 1: Write the page component** + +Regra do botão Desconectar: desabilitado quando `connected && totalMethods <= 1` (onde `totalMethods = providers conectados + (hasPassword ? 1 : 0)`). Lê `?linked=`/`?error=` para feedback. + +```tsx +// frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + connectProvider, + disconnectProvider, + getConnections, + type SupportedProvider, + type UserConnections, +} from "@/domains/auth/infrastructure/connectionsApi"; + +const PROVIDER_LABELS: Record = { + google: "Google", + linkedin: "LinkedIn", + github: "GitHub", +}; + +const ERROR_MESSAGES: Record = { + provider_already_linked: "Essa conta já está vinculada a outro usuário.", + link_failed: "Não foi possível conectar. Tente novamente.", +}; + +export default function ConnectionsPage() { + const [data, setData] = useState(null); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + async function refresh() { + try { + setData(await getConnections()); + } catch { + setError("Falha ao carregar conexões."); + } + } + + useEffect(() => { + refresh(); + }, []); + + useEffect(() => { + const linked = searchParams.get("linked"); + const errCode = searchParams.get("error"); + if (linked) setFeedback(`Conta ${linked} conectada com sucesso.`); + if (errCode) setError(ERROR_MESSAGES[errCode] ?? "Erro ao conectar."); + if (linked || errCode) { + searchParams.delete("linked"); + searchParams.delete("error"); + setSearchParams(searchParams, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const totalMethods = useMemo(() => { + if (!data) return 0; + const connected = data.connections.filter((c) => c.connected).length; + return connected + (data.hasPassword ? 1 : 0); + }, [data]); + + async function handleDisconnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + try { + await disconnectProvider(provider); + setFeedback(`Conta ${PROVIDER_LABELS[provider]} desconectada.`); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao desconectar."); + } finally { + setBusy(null); + } + } + + async function handleConnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + try { + await connectProvider(provider); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao conectar."); + setBusy(null); + } + } + + return ( +
+

Gerenciar conexões

+

Conecte ou desconecte suas contas de login social.

+ + {feedback &&

{feedback}

} + {error &&

{error}

} + + {!data &&

Carregando…

} + + {data && ( +
    + {data.connections.map((c) => { + const isLastMethod = c.connected && totalMethods <= 1; + return ( +
  • + + {PROVIDER_LABELS[c.provider]}{" "} + {c.connected ? "· Conectado" : "· Não conectado"} + + {c.connected ? ( + + ) : ( + + )} +
  • + ); + })} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Add the route** + +Em `frontend/src/app/AppRoutes.tsx`, importe o componente e adicione a rota junto às demais (ex.: depois de `/auth/callback`). Use o mesmo layout do dashboard: + +```tsx +import ConnectionsPage from "@/domains/auth/presentation/pages/ConnectionsPage"; +``` + +```tsx + + + + + + } + /> +``` + +> Nota: `NewDashboardLayout` já é importado em `AppRoutes.tsx` (usado no `dashboardElement`). React Router v6 rankeia por especificidade, então `/perfil/conexoes` tem precedência sobre `/perfil` independentemente da ordem. + +- [ ] **Step 3: Typecheck + build** + +Run: `cd frontend && npx tsc --noEmit` +Expected: sem erros. + +- [ ] **Step 4: Manual smoke (com backend + infra rodando)** + +1. Logado, acesse `http://localhost:5173/perfil/conexoes`. +2. Clique **Conectar** num provider não conectado → completa OAuth → volta com `?linked=` e o provider aparece **Conectado**. +3. Com 2+ métodos, **Desconectar** um → some da lista de conectados. +4. Com só 1 método, o botão **Desconectar** fica desabilitado. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx frontend/src/app/AppRoutes.tsx +git commit -m "feat(PAV-7): tela Gerenciar conexões e rota /perfil/conexoes" +``` + +--- + +## Self-Review (feito pelo autor do plano) + +**Spec coverage:** +- Modelo `authProviders` → já existe (`accounts`); sem task, documentado no spec. ✔ +- Vincular quando email existe / não duplicar → já existe (`findOrCreateUser`) + reforço via Task 1 (conflito). ✔ +- Conectar logado → Task 5 (modo vínculo) + Task 6/7 (frontend). ✔ +- Desconectar com validação → Task 2 + Task 4 (rota) + Task 7 (UI). ✔ +- Listar conexões → Task 3 + Task 4 + Task 7. ✔ +- Tela "Gerenciar conexões" → Task 7. ✔ +- AC (Google+LinkedIn juntos; sem duplicidade) → coberto por Tasks 1, 5, 7. ✔ + +**Type consistency:** `SUPPORTED_PROVIDERS`/`ConnectionStatus`/`UserConnections` definidos na Task 3 e reusados nas Tasks 4/6/7 com os mesmos campos (`provider`, `connected`, `connectedAt`, `hasPassword`). Backend usa `Date`, frontend usa `string` (serialização JSON) — intencional e documentado. + +**Placeholder scan:** nenhum TODO/"handle errors" genérico; todo passo tem código real. diff --git a/docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md b/docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md new file mode 100644 index 0000000..608c8d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md @@ -0,0 +1,126 @@ +# PAV-7 — Vincular conta social a usuário local + Gerenciar conexões + +**Ticket:** [PAV-7](https://linear.app/tatame/issue/PAV-7) · EPIC 1 — Autenticação + Login Social +**Branch:** `feature/pav-7-gerenciar-conexoes` +**Data:** 2026-07-27 + +## Contexto + +Boa parte do backend já existe: + +- A tabela `accounts` (`src/db/schema/accounts.ts`) já é o modelo de providers pedido pelo ticket: + `userId`, `provider`, `providerAccountId`, tokens, com `uniqueIndex(provider, providerAccountId)`. +- `findOrCreateUser` (`src/modules/users/functions/findOrCreateUser.ts`) já faz o **merge** no login: + busca por provider → senão por email (vincula ao usuário existente sem duplicar) → senão cria usuário + account. + Isso já satisfaz o critério "não cria duplicidade quando o email já existe". +- Login OAuth (`google`, `linkedin`, `github`) e login local (email/senha via `credentials`) funcionam. +- Sessão via `req.session` (`userId`, `role`); OAuth guarda `oauth_state` na sessão e valida no callback. + +**Falta** para fechar o PAV-7 (escopo desta branch — os 4 itens): + +1. Conectar um provider estando **já logado** (hoje o OAuth só faz login). +2. Desconectar um provider, com validação para não trancar o usuário para fora. +3. Listar as conexões do usuário atual. +4. Tela "Gerenciar conexões" no frontend. + +## Decisões de produto + +- **Conectar logado (modo vínculo):** reusar o fluxo OAuth atual com uma flag de intenção (`intent=link`), + em vez de endpoints/redirect URIs novos. Motivo: não exige cadastrar novas redirect URIs nos consoles + OAuth (Google/LinkedIn/GitHub), reaproveitando o `state`/CSRF e o código de provider já existentes. +- **Regra de desconexão:** manter **≥ 1 método de login**. Conta como método: qualquer provider vinculado + **ou** senha (`credentials`). Só bloqueia remover o **último** método. Usuário pode ser só-OAuth. +- **Conflito ao conectar:** se a conta do provider já estiver vinculada a **outro** usuário, **bloquear com erro** + (`provider_already_linked`). Não migra nem faz merge silencioso. + +## Modelo de dados + +Sem mudança de schema. Nenhuma migration nova. `accounts` e `credentials` já cobrem o necessário. + +## Backend + +### Service + +Novos métodos (reusando `findUserByProvider`, `createAccount` e a leitura de `credentials`): + +- **`linkProvider(userId, provider, profile)`** + - Busca `account` por `(provider, providerAccountId = profile.id)`. + - Já vinculado ao **próprio** `userId` → sucesso idempotente (no-op). + - Vinculado a **outro** usuário → lança `provider_already_linked`. + - Senão → `createAccount({ userId, provider, profile })`. +- **`listConnections(userId)`** + - Retorna, para cada provider suportado (`google`, `linkedin`, `github`): + `{ provider, connected: boolean, connectedAt?: Date }`. + - Mais `hasPassword: boolean` (existe linha em `credentials` para o usuário). +- **`disconnectProvider(userId, provider)`** + - `methodsAfter = (providers vinculados distintos, exceto o removido) + (hasPassword ? 1 : 0)`. + - Se `methodsAfter === 0` → lança `cannot_remove_last_method`. + - Senão → deleta a(s) linha(s) de `accounts` de `(userId, provider)`. + +### Rotas (`src/routes/auth.routes.ts`) + +- **Conectar:** reusa `GET /auth/:provider/url`, aceitando query `?intent=link`. + No `AuthController.getUrl`, quando `intent=link`, guarda `oauth_intent="link"` na sessão + (ao lado de `oauth_state`). +- **Callback:** em `AuthController.callback`, após validar o `state` (lógica atual inalterada): + - Se `oauth_intent === "link"` **e** `req.session.userId` existe: + - Obtém o profile via provider (`getProfileFromProvider`). + - Chama `linkProvider(req.session.userId, provider, profile)`. + - **Não** altera a sessão. + - Redireciona para `${FRONTEND_URL}/perfil/conexoes?linked=` + (ou `?error=` em falha). + - Senão → fluxo de login atual (cria/atualiza sessão, redireciona para `/auth/callback`). + - Sempre limpa `oauth_intent` ao final (sucesso ou erro). +- **Listar:** `GET /auth/connections` (com `requireAuth`) → `listConnections(req.session.userId)`. +- **Desconectar:** `DELETE /auth/connections/:provider` (com `requireAuth`) → `disconnectProvider(...)`. + +As rotas `/connections` e `/connections/:provider` não colidem com `/:provider/url` e +`/:provider/callback` (segmentos e/ou métodos distintos). + +### Tratamento de erros + +Códigos mapeados no `errorHandler` para HTTP: + +- `provider_already_linked` → 409 +- `cannot_remove_last_method` → 409 + +No modo link, falhas no callback viram redirect `?error=` (o frontend traduz para PT-BR). + +## Frontend + +- **Rota:** `/perfil/conexoes`, dentro do `ProtectedRoute` (`src/app/AppRoutes.tsx`). +- **API client** `connectionsApi.ts` (segue o padrão de `authApi.ts` / `getBaseUrl`/`buildUrl`): + - `getConnections()` → `GET /auth/connections`. + - `disconnect(provider)` → `DELETE /auth/connections/:provider`. + - `connect(provider)` → chama `GET /auth/:provider/url?intent=link` e faz `window.location = url`. +- **UI:** lista os 3 providers (`google`, `linkedin`, `github`) com estado **Conectado / Não conectado**: + - **Conectar** (não conectado) → inicia OAuth em modo vínculo. + - **Desconectar** (conectado) → confirmação, depois `disconnect`. + - Botão Desconectar **desabilitado** quando for o último método (front calcula por `connections` + `hasPassword`; + o backend revalida como fonte da verdade). + - Ao retornar do OAuth, lê `?linked=` / `?error=` da URL e exibe toast de sucesso/erro. + +## Testes + +- **Unit (service):** + - `linkProvider`: idempotente (mesmo usuário), conflito (`provider_already_linked`), vínculo novo. + - `disconnectProvider`: bloqueia último método (`cannot_remove_last_method`), remove quando há outro método. + - `listConnections`: estados conectado/não conectado e `hasPassword`. +- **Integration (rotas):** + - `GET /auth/connections` (autenticado vs 401). + - `DELETE /auth/connections/:provider` (200 e 409 no último método). + - Callback com `intent=link`: vincula sem trocar a sessão; conflito redireciona com `?error=`. +- Seguir os padrões de `tests/unit/modules/auth` e `tests/integration/routes`. + +## Fora de escopo (YAGNI) + +- Re-autenticação antes de desconectar. +- Exibir email/detalhes da conta vinculada na tela. +- Novas migrations ou colunas. +- Alterar o comportamento de merge-por-email do login atual. + +## Critérios de aceite (do ticket) + +- Um usuário pode ter Google + LinkedIn vinculados. ✔ via `linkProvider` + tela. +- Não cria duplicidade quando o email já existe. ✔ já garantido por `findOrCreateUser`; reforçado por + `provider_already_linked` no modo vínculo. diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index f165331..f60d67b 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -58,6 +58,10 @@ export function AppRoutes() { } /> } /> + } + /> } /> ); diff --git a/frontend/src/domains/auth/infrastructure/connectionsApi.ts b/frontend/src/domains/auth/infrastructure/connectionsApi.ts new file mode 100644 index 0000000..7dce887 --- /dev/null +++ b/frontend/src/domains/auth/infrastructure/connectionsApi.ts @@ -0,0 +1,59 @@ +export type SupportedProvider = "google" | "linkedin" | "github"; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: string | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +function getBaseUrl(): string { + const base = import.meta.env.VITE_API_BASE_URL; + if (base && base.trim().length > 0) return base.replace(/\/+$/, ""); + return ""; +} + +function buildUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + const base = getBaseUrl(); + return base ? `${base}${normalized}` : normalized; +} + +export async function getConnections(): Promise { + const response = await fetch(buildUrl("/auth/connections"), { + credentials: "include", + }); + if (!response.ok) throw new Error("Falha ao carregar conexões."); + return (await response.json()) as UserConnections; +} + +export async function disconnectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch(buildUrl(`/auth/connections/${provider}`), { + method: "DELETE", + credentials: "include", + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + message?: string; + }; + throw new Error(payload.message ?? "Falha ao desconectar."); + } +} + +export async function connectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch( + buildUrl(`/auth/${provider}/url?intent=link`), + { credentials: "include" }, + ); + if (!response.ok) throw new Error("Falha ao iniciar conexão."); + const { url } = (await response.json()) as { url: string }; + window.location.href = url; +} diff --git a/frontend/src/domains/new_dashboard/components/layout/Header.tsx b/frontend/src/domains/new_dashboard/components/layout/Header.tsx index d403888..e8a656e 100644 --- a/frontend/src/domains/new_dashboard/components/layout/Header.tsx +++ b/frontend/src/domains/new_dashboard/components/layout/Header.tsx @@ -392,13 +392,6 @@ export function Header({ > Meu Perfil - + ) : ( + + )} + + ); + })} + + )} + + + ); +} diff --git a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx index 762c475..4b9fe5b 100644 --- a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx +++ b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx @@ -1,4 +1,5 @@ import type { SearchPreferences, UserProfile } from "../../types"; +import { ConnectionsForm } from "./ConnectionsForm"; import { PreferencesForm } from "./PreferencesForm"; import { ProfileForm } from "./ProfileForm"; @@ -37,6 +38,7 @@ export function ProfileTab({ isSaving={isSavingPreferences} onSave={onSavePreferences} /> + ); } diff --git a/frontend/tests/unit/hooks/useJobsData.extended.test.tsx b/frontend/tests/unit/hooks/useJobsData.extended.test.tsx index 44c5ba8..8ec2036 100644 --- a/frontend/tests/unit/hooks/useJobsData.extended.test.tsx +++ b/frontend/tests/unit/hooks/useJobsData.extended.test.tsx @@ -67,6 +67,38 @@ describe("useJobsData extended", () => { expect(result.current.error).toBe("falha jobs"); }); + it("aplica os metadados padrão quando a API retorna payload parcial", async () => { + mocks.fetchJobsByAPIMock.mockResolvedValueOnce({}); + + const { result } = renderHook(() => useJobsData(3, 10)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.meta).toEqual({ + total: 0, + hasNext: false, + hasPrev: false, + page: 3, + limit: 10, + totalPages: 0, + }); + }); + + expect(result.current.jobs).toEqual([]); + }); + + it("usa mensagem padrão quando carregar vagas rejeita sem Error", async () => { + mocks.fetchJobsByAPIMock.mockRejectedValueOnce("offline"); + + const { result } = renderHook(() => useJobsData()); + + await waitFor(() => { + expect(result.current.error).toBe( + "Erro inesperado ao carregar vagas.", + ); + }); + }); + it("triggerScraper carrega jobs após executar request", async () => { const { result } = renderHook(() => useJobsData()); @@ -78,4 +110,23 @@ describe("useJobsData extended", () => { expect(mocks.fetchJobsByAPIMock).toHaveBeenCalled(); expect(result.current.scraping).toBe(false); }); + + it("usa mensagem padrão quando o scraper rejeita sem Error", async () => { + mocks.runScraperRequestMock.mockRejectedValueOnce("indisponível"); + + const { result } = renderHook(() => useJobsData()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.triggerScraper(); + }); + + expect(result.current.error).toBe( + "Erro inesperado ao executar o scraper.", + ); + expect(result.current.scraping).toBe(false); + }); }); diff --git a/frontend/tests/unit/new_dashboard/HeaderUserMenu.test.tsx b/frontend/tests/unit/new_dashboard/HeaderUserMenu.test.tsx new file mode 100644 index 0000000..8226b50 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/HeaderUserMenu.test.tsx @@ -0,0 +1,71 @@ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Header } from "@/domains/new_dashboard/components/layout/Header"; + +const mockNavigate = vi.fn(); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: "/home" }), + useNavigate: () => mockNavigate, +})); + +vi.mock("@/domains/auth/application/AuthContext", () => ({ + useAuth: () => ({ + user: { id: "1", name: "Joao Silva", email: "joao@teste.com" }, + logout: vi.fn(), + }), +})); + +vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ + getDashboardNotificationFeed: vi.fn().mockResolvedValue({ + messages: [], + notifications: [], + unreadCount: 0, + }), + markDashboardNotificationsRead: vi.fn().mockResolvedValue(undefined), + clearDashboardNotifications: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/domains/new_dashboard/components/layout/ThemeToggle", () => ({ + ThemeToggle: () => , +})); + +vi.mock("@/domains/new_dashboard/components/layout/MessageDetailModal", () => ({ + MessageDetailModal: () => null, +})); + +function openUserMenu() { + render(
); + fireEvent.click(screen.getByLabelText("Menu do usuário")); +} + +describe("Header - dropdown do usuário (PAV-108)", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + it("AC1/AC4: não exibe a opção 'Segurança' no dropdown superior", () => { + openUserMenu(); + + expect( + screen.queryByRole("button", { name: "Segurança" }), + ).not.toBeInTheDocument(); + }); + + it("AC2: exibe a opção 'Ajuda' no dropdown superior", () => { + openUserMenu(); + + expect( + screen.getByRole("button", { name: "Ajuda" }), + ).toBeInTheDocument(); + }); + + it("AC3: 'Ajuda' navega para /ajuda (mesmo destino da Ajuda inferior)", () => { + openUserMenu(); + + fireEvent.click(screen.getByRole("button", { name: "Ajuda" })); + + expect(mockNavigate).toHaveBeenCalledWith("/ajuda"); + }); +}); diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index 8af7f13..765865e 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -6,6 +6,7 @@ import { Header } from "@/domains/new_dashboard/components/layout/Header"; import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal"; import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow"; +import { MentoringTab } from "@/domains/new_dashboard/components/mentoring/MentoringTab"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { @@ -236,6 +237,27 @@ describe("new_dashboard branch coverage", () => { expect(onClose).toHaveBeenCalledOnce(); }); + it("abre, confirma e fecha os detalhes de uma mentoria", () => { + render(); + + fireEvent.click(screen.getAllByRole("button", { name: "Ver Mais" })[0]); + + expect( + screen.getByRole("heading", { name: "Julio Silva", level: 2 }), + ).toBeInTheDocument(); + expect(screen.queryByText(/participação confirmada/i)).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: /confirmar participação/i }), + ); + expect(screen.getByText(/participação confirmada/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Fechar" })); + expect( + screen.queryByRole("heading", { name: "Julio Silva", level: 2 }), + ).not.toBeInTheDocument(); + }); + it("usa origem sistema como fallback no detalhe da mensagem", () => { render( ({ + getConnections: vi.fn(), + connectProvider: vi.fn(), + disconnectProvider: vi.fn(), +})); + +vi.mock("@/domains/auth/infrastructure/connectionsApi", () => ({ + getConnections: mocks.getConnections, + connectProvider: mocks.connectProvider, + disconnectProvider: mocks.disconnectProvider, +})); + +const dataWithPassword = { + hasPassword: true, + connections: [ + { provider: "google", connected: true, connectedAt: "2024-01-01" }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ], +}; + +const dataOnlyOneMethod = { + hasPassword: false, + connections: [ + { provider: "google", connected: true, connectedAt: "2024-01-01" }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ], +}; + +function renderForm(initialEntries: string[] = ["/perfil"]) { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getConnections.mockResolvedValue(dataWithPassword); + mocks.connectProvider.mockResolvedValue(undefined); + mocks.disconnectProvider.mockResolvedValue(undefined); +}); + +describe("ConnectionsForm", () => { + it("mostra carregando e depois lista as conexões", async () => { + renderForm(); + + expect(screen.getByText(/carregando/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("Google")).toBeInTheDocument(); + expect(screen.getByText("LinkedIn")).toBeInTheDocument(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); + }); + expect(screen.getByText("Conectado")).toBeInTheDocument(); + expect(screen.getAllByText("Não conectado")).toHaveLength(2); + }); + + it("exibe feedback de sucesso a partir de ?linked=", async () => { + renderForm(["/perfil?linked=google"]); + + expect( + screen.getByText(/conta google conectada com sucesso/i), + ).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText("Google")).toBeInTheDocument(), + ); + }); + + it("exibe mensagem mapeada a partir de ?error= conhecido", async () => { + renderForm(["/perfil?error=provider_already_linked"]); + + expect( + screen.getByText(/já está vinculada a outro usuário/i), + ).toBeInTheDocument(); + }); + + it("exibe mensagem genérica a partir de ?error= desconhecido", async () => { + renderForm(["/perfil?error=algo_inesperado"]); + + expect(screen.getByText("Erro ao conectar.")).toBeInTheDocument(); + }); + + it("mostra erro quando o carregamento de conexões falha", async () => { + mocks.getConnections.mockRejectedValueOnce(new Error("network")); + renderForm(); + + await waitFor(() => + expect(screen.getByText("Falha ao carregar conexões.")).toBeInTheDocument(), + ); + }); + + it("desconecta um provider conectado e mostra feedback", async () => { + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Desconectar" })); + + await waitFor(() => + expect(mocks.disconnectProvider).toHaveBeenCalledWith("google"), + ); + await waitFor(() => + expect( + screen.getByText("Conta Google desconectada."), + ).toBeInTheDocument(), + ); + // refresh: carrega novamente após desconectar + expect(mocks.getConnections).toHaveBeenCalledTimes(2); + }); + + it("mostra a mensagem de erro ao falhar a desconexão (Error)", async () => { + mocks.disconnectProvider.mockRejectedValueOnce(new Error("boom")); + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Desconectar" })); + + await waitFor(() => expect(screen.getByText("boom")).toBeInTheDocument()); + }); + + it("usa fallback quando a desconexão falha sem Error", async () => { + mocks.disconnectProvider.mockRejectedValueOnce("x"); + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Desconectar" })); + + await waitFor(() => + expect(screen.getByText("Falha ao desconectar.")).toBeInTheDocument(), + ); + }); + + it("inicia a conexão de um provider não conectado", async () => { + renderForm(); + await waitFor(() => + expect(screen.getByText("LinkedIn")).toBeInTheDocument(), + ); + + // Ordem: google (Desconectar), linkedin, github (Conectar) + fireEvent.click(screen.getAllByRole("button", { name: "Conectar" })[0]); + + await waitFor(() => + expect(mocks.connectProvider).toHaveBeenCalledWith("linkedin"), + ); + }); + + it("mostra a mensagem de erro ao falhar o início da conexão", async () => { + mocks.connectProvider.mockRejectedValueOnce(new Error("fail-connect")); + renderForm(); + await waitFor(() => + expect(screen.getByText("LinkedIn")).toBeInTheDocument(), + ); + + fireEvent.click(screen.getAllByRole("button", { name: "Conectar" })[0]); + + await waitFor(() => + expect(screen.getByText("fail-connect")).toBeInTheDocument(), + ); + }); + + it("desabilita desconectar quando é o único método de login", async () => { + mocks.getConnections.mockResolvedValue(dataOnlyOneMethod); + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + const disconnect = screen.getByRole("button", { name: "Desconectar" }); + expect(disconnect).toBeDisabled(); + expect(disconnect).toHaveAttribute( + "title", + "Você precisa manter ao menos um método de login.", + ); + }); +}); diff --git a/frontend/tests/unit/new_dashboard/theme.toast.test.tsx b/frontend/tests/unit/new_dashboard/theme.toast.test.tsx index c58be1b..312d26e 100644 --- a/frontend/tests/unit/new_dashboard/theme.toast.test.tsx +++ b/frontend/tests/unit/new_dashboard/theme.toast.test.tsx @@ -105,6 +105,12 @@ describe("new_dashboard theme and toast contexts", () => { expect(container).toBeEmptyDOMElement(); }); + it("renderiza toast quando recebe uma mensagem", () => { + render(); + + expect(screen.getByText("Perfil atualizado")).toBeInTheDocument(); + }); + it("lança erro quando o contexto de toast é usado fora do provider", () => { expect(() => render()).toThrow( /useToastContext must be used within ToastProvider/i, diff --git a/frontend/tests/unit/services/auth.service.test.tsx b/frontend/tests/unit/services/auth.service.test.tsx index 96c888d..ae0773a 100644 --- a/frontend/tests/unit/services/auth.service.test.tsx +++ b/frontend/tests/unit/services/auth.service.test.tsx @@ -33,6 +33,50 @@ describe("authService", () => { beforeEach(() => { fetchMock.mockReset(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe("response parsing and URL normalization", () => { + it("uses the configured API URL without trailing slashes", async () => { + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com///"); + fetchMock.mockResolvedValueOnce(mockResponse({ jsonData: { ok: true } })); + + await expect(auth.logout()).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/auth/logout", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("parses non-JSON response bodies as messages", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ + contentType: "text/plain", + textData: "Logout concluído", + }), + ); + + await expect(auth.logout()).resolves.toEqual({ + message: "Logout concluído", + }); + }); + + it("returns an empty payload for an empty response body", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ contentType: "", textData: "" }), + ); + + await expect(auth.logout()).resolves.toEqual({}); + }); + + it("returns an empty payload when response parsing fails", async () => { + const response = mockResponse({ contentType: "application/json" }); + response.json.mockRejectedValueOnce(new SyntaxError("invalid JSON")); + fetchMock.mockResolvedValueOnce(response); + + await expect(auth.logout()).resolves.toEqual({}); + }); }); describe("login", () => { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 3913373..bcd59f4 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -19,6 +19,13 @@ export default defineConfig({ "^/(auth|users|jobs|keywords|saved-jobs)": { target: apiTarget, changeOrigin: true, + // Deixa navegações de página (ex: redirect OAuth para /auth/callback) + // caírem no index.html do SPA; só proxia chamadas de API (fetch/XHR). + bypass(req) { + if (req.method === "GET" && req.headers.accept?.includes("text/html")) { + return req.url; + } + }, }, }, },