mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C139): schemas e actions de contato — criação staff e edição por célula
Fase 3 (server) do plano aprovado:
- schemas/contact.ts: contactCreateSchema (nome único via invariante, state
obrigatório, phones opcionais C112); contactFieldUpdateSchema ganha variantes
city e postalCode ('' → clear); mensagens das actions client-safe aqui.
- schemas/personGenders.ts: enum de gênero em módulo folha (ciclo de imports
contact ↔ leadership detectado e quebrado).
- actions/contact.ts: createContact (gate staff + bypass justificado, lock
contact-name:{normalized}) e updateContactField (escopo 'edita o que vê'
via re-check user-threaded para advisor; reorder de telefone primário).
Apagar reusa as actions C100 existentes.
- collections Contact e Supporter: label de gênero 'Outro' → 'Não binário'
(sem migration; nenhum teste pina a label).
- 11 testes unit novos; lint, tsc e prettier limpos.
This commit is contained in:
parent
1daaaf9f4e
commit
e71b062650
11 changed files with 392 additions and 51 deletions
170
src/app/(campaign)/campanha/actions/contact.ts
Normal file
170
src/app/(campaign)/campanha/actions/contact.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import {
|
||||
CONTACT_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
CONTACT_CELL_STAFF_MESSAGE,
|
||||
CONTACT_CREATE_STAFF_MESSAGE,
|
||||
contactCreateSchema,
|
||||
contactFieldUpdateSchema,
|
||||
type ContactCreateInput,
|
||||
type ContactFieldUpdateInput,
|
||||
} from '@/lib/schemas/contact'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { assertContactNameAvailable } from '@/utilities/contacts/contactNameInvariant'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
} from '@/utilities/payloadTransaction'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/**
|
||||
* C139 — the contacts page writes the ficha itself, so the write gate is the
|
||||
* row's own read visibility: "you edit what you see". The `contact` collection
|
||||
* access keeps `create`/`update` admin-only (`canManageContacts`), so every
|
||||
* campaign write bypasses with `overrideAccess: true` AFTER the staff gate and
|
||||
* (for an advisor) a user-threaded scope re-check — the bypass is justified by
|
||||
* the gate, never standalone.
|
||||
*/
|
||||
|
||||
const assertContactRowEditable = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
contactID: number,
|
||||
req: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
if (isCampaignUnrestricted(actor)) return
|
||||
|
||||
const visible = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { id: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
export const createContactRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactCreateInput,
|
||||
): Promise<{ contactID: number }> => {
|
||||
const data = contactCreateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(payload, async ({ req }) => {
|
||||
await reloadStaffActor(payload, actor, CONTACT_CREATE_STAFF_MESSAGE, req)
|
||||
|
||||
await assertContactNameAvailable(payload, req, data.name)
|
||||
|
||||
const created = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: data.name,
|
||||
...(data.email ? { email: data.email } : {}),
|
||||
...(data.phones && data.phones.length > 0
|
||||
? { phones: data.phones.map((value) => ({ value })) }
|
||||
: {}),
|
||||
...(data.gender ? { gender: data.gender } : {}),
|
||||
state: data.state,
|
||||
...(data.city ? { city: data.city } : {}),
|
||||
...(data.postalCode ? { postalCode: data.postalCode } : {}),
|
||||
},
|
||||
depth: 0,
|
||||
// Intentional bypass: the staff gate above is the authorization; the
|
||||
// collection's `create` access is admin-only by design (C139).
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return { contactID: created.id }
|
||||
})
|
||||
}
|
||||
|
||||
export const createContact = async (input: unknown) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const { contactID } = await createContactRecord(payload, actor, input as ContactCreateInput)
|
||||
revalidatePath('/campanha/contatos', 'page')
|
||||
return { contactID }
|
||||
}
|
||||
|
||||
const contactFieldData = (
|
||||
data: ContactFieldUpdateInput,
|
||||
): Partial<Pick<Contact, 'name' | 'email' | 'city' | 'postalCode'>> & {
|
||||
phones?: { value: string }[]
|
||||
} => {
|
||||
switch (data.field) {
|
||||
case 'name':
|
||||
return { name: data.name }
|
||||
case 'email':
|
||||
return { email: data.email ?? null }
|
||||
case 'city':
|
||||
return { city: data.city }
|
||||
case 'postalCode':
|
||||
// The schema transforms '' → undefined: clearing the CEP stores null.
|
||||
return { postalCode: data.postalCode ?? null }
|
||||
case 'phones':
|
||||
return { phones: data.phones.map((value) => ({ value })) }
|
||||
case 'phone':
|
||||
return {
|
||||
phones: data.phone === null || data.phone === undefined ? [] : [{ value: data.phone }],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateContactFieldRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactFieldUpdateInput,
|
||||
): Promise<void> => {
|
||||
const data = contactFieldUpdateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(payload, async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(payload, actor, CONTACT_CELL_STAFF_MESSAGE, req)
|
||||
|
||||
await assertContactRowEditable(payload, currentActor, data.id, req)
|
||||
|
||||
let contactData = contactFieldData(data)
|
||||
if (data.field === 'name') {
|
||||
await assertContactNameAvailable(payload, req, data.name, data.id)
|
||||
} else if (data.field === 'phone') {
|
||||
// Inline primary-phone edit (C112 shape): the new number goes first and
|
||||
// the rest shifts up; clearing drops the primary phone.
|
||||
const current = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: data.id,
|
||||
depth: 0,
|
||||
select: { phones: true },
|
||||
// Intentional bypass: the scope check above established the actor's
|
||||
// right over this row; the read only resolves the list to reorder.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
contactData = {
|
||||
phones: reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({ value })),
|
||||
}
|
||||
}
|
||||
|
||||
// Intentional bypass: same gate as the reads above.
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: data.id,
|
||||
data: contactData,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const updateContactField = async (input: unknown) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await updateContactFieldRecord(payload, actor, input as ContactFieldUpdateInput)
|
||||
revalidatePath('/campanha/contatos', 'page')
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ export const Contact: CollectionConfig = {
|
|||
options: [
|
||||
{ label: 'Feminino', value: 'feminino' },
|
||||
{ label: 'Masculino', value: 'masculino' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
{ label: 'Não binário', value: 'outro' },
|
||||
{ label: 'Não informado', value: 'nao_informado' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const Supporter: CollectionConfig = {
|
|||
{ label: 'Certo', value: 'certo' },
|
||||
{ label: 'Tende a certo', value: 'tende_a_certo' },
|
||||
{ label: 'Indeciso', value: 'indeciso' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
{ label: 'Não binário', value: 'outro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CitiesByState } from '@/lib/cities'
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
|
||||
import { personGenders } from '@/lib/schemas/personGenders'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
|
|
@ -10,34 +11,25 @@ import {
|
|||
|
||||
export type StateKey = keyof typeof CitiesByState
|
||||
|
||||
export const contactSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Nome deve ter pelo menos 2 caracteres')
|
||||
.max(120, 'Nome deve ter no máximo 100 caracteres')
|
||||
.regex(
|
||||
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
|
||||
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
|
||||
),
|
||||
email: z.email('Email inválido'),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(11, 'Telefone celular inválido')
|
||||
.regex(/^\d{11}$/, 'Telefone celular inválido'),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
|
||||
postalCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(?:\d{8})?$/, 'CEP inválido')
|
||||
.transform((v) => (v === '' ? undefined : v))
|
||||
.optional(),
|
||||
})
|
||||
/** Gender enum shared with the leadership collection (same values, C139). */
|
||||
export const contactGenders = personGenders
|
||||
|
||||
const contactNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Nome deve ter pelo menos 2 caracteres')
|
||||
.max(120, 'Nome deve ter no máximo 100 caracteres')
|
||||
.regex(
|
||||
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
|
||||
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
|
||||
)
|
||||
|
||||
const contactPostalCodeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(?:\d{8})?$/, 'CEP inválido')
|
||||
.transform((v) => (v === '' ? undefined : v))
|
||||
.optional()
|
||||
|
||||
const contactPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
|
||||
|
||||
|
|
@ -59,6 +51,55 @@ export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones,
|
|||
}
|
||||
})
|
||||
|
||||
export const contactSchema = z.object({
|
||||
name: contactNameSchema,
|
||||
email: z.email('Email inválido'),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(11, 'Telefone celular inválido')
|
||||
.regex(/^\d{11}$/, 'Telefone celular inválido'),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
|
||||
postalCode: contactPostalCodeSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* C139 — the create form of the contacts page: the ficha fields a staff actor
|
||||
* fills in one shot (name required and unique — the invariant runs in the
|
||||
* action under an advisory lock; phones follow the C112 shape).
|
||||
*/
|
||||
export const contactCreateSchema = z.object({
|
||||
name: contactNameSchema,
|
||||
email: optionalPersistedEmail,
|
||||
// Optional like the collection: name-only fichas exist (C111); the form
|
||||
// sends [] when the actor typed no phone.
|
||||
phones: contactPhonesSchema.optional(),
|
||||
gender: z.enum(contactGenders).optional(),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa').optional(),
|
||||
postalCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(?:\d{8})?$/, 'CEP inválido')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type ContactCreateInput = z.input<typeof contactCreateSchema>
|
||||
|
||||
export const CONTACT_NAME_CONFLICT_MESSAGE =
|
||||
'Já existe um contato com este nome — confira a lista antes de salvar.'
|
||||
export const CONTACT_CELL_STAFF_MESSAGE = 'Apenas a equipe pode editar fichas.'
|
||||
export const CONTACT_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta ficha não está no seu escopo.'
|
||||
export const CONTACT_CREATE_STAFF_MESSAGE = 'Apenas a equipe pode criar contatos.'
|
||||
export const CONTACT_CREATED_MESSAGE = 'Contato criado.'
|
||||
|
||||
/** Per-field Contact write shared by campaign person joins. */
|
||||
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
||||
z.object({
|
||||
|
|
@ -81,6 +122,19 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
|||
field: z.literal('phones'),
|
||||
phones: contactPhonesSchema,
|
||||
}),
|
||||
// C139 — the contacts page edits the ficha's own fields in place: city and
|
||||
// CEP are free text on the Contact collection itself (no person-join
|
||||
// sibling writes them).
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('city'),
|
||||
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
|
||||
}),
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('postalCode'),
|
||||
postalCode: contactPostalCodeSchema,
|
||||
}),
|
||||
])
|
||||
|
||||
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
import { personGenders } from '@/lib/schemas/personGenders'
|
||||
import {
|
||||
optionalPersistedEmail,
|
||||
positiveRelationshipId,
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
trimmedOptionalText,
|
||||
} from '@/lib/schemas/primitives'
|
||||
|
||||
export const leadershipGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
|
||||
export const leadershipGenders = personGenders
|
||||
|
||||
export const leadershipSupportStatuses = [
|
||||
'engajado',
|
||||
|
|
|
|||
9
src/lib/schemas/personGenders.ts
Normal file
9
src/lib/schemas/personGenders.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* The gender enum shared by every person-ish collection (contact, leadership,
|
||||
* dobradinha, supporter). Leaf module on purpose: `contact.ts` and
|
||||
* `leadership.ts` schemas import it from here — a re-export through either
|
||||
* would be a module cycle.
|
||||
*/
|
||||
export const personGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
|
||||
|
||||
export type PersonGender = (typeof personGenders)[number]
|
||||
|
|
@ -36,7 +36,6 @@ export const contactGenderLabels: Record<ContactGender, string> = {
|
|||
outro: 'Não binário',
|
||||
nao_informado: 'Não informado',
|
||||
}
|
||||
|
||||
export const CONTACT_ABSENCES = ['sem_telefone', 'sem_email'] as const
|
||||
export type ContactAbsence = (typeof CONTACT_ABSENCES)[number]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ import 'server-only'
|
|||
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
export const CONTACT_NAME_CONFLICT_MESSAGE =
|
||||
'Já existe um contato com este nome — confira a lista antes de salvar.'
|
||||
|
||||
type ContactNameRequest = Pick<PayloadRequest, 'transactionID'>
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Contact } from '@/payload-types'
|
||||
|
||||
import {
|
||||
contactFilterFacetsFromRows,
|
||||
filterContactRows,
|
||||
|
|
@ -56,7 +58,7 @@ describe('toContactRowViewModel', () => {
|
|||
state: 'BA',
|
||||
city: 'Camaçari',
|
||||
postalCode: '42800-000',
|
||||
} as never)
|
||||
} as unknown as Contact)
|
||||
expect(view).toEqual({
|
||||
contactID: 7,
|
||||
name: ' Maria ',
|
||||
|
|
@ -80,7 +82,7 @@ describe('toContactRowViewModel', () => {
|
|||
state: 'BAHIA',
|
||||
city: null,
|
||||
postalCode: null,
|
||||
} as never)
|
||||
} as unknown as Contact)
|
||||
expect(view).toEqual({
|
||||
contactID: 8,
|
||||
name: 'Sem Dados',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact'
|
||||
import {
|
||||
assertContactNameAvailable,
|
||||
CONTACT_NAME_CONFLICT_MESSAGE,
|
||||
normalizeContactName,
|
||||
} from '@/utilities/contacts/contactNameInvariant'
|
||||
|
||||
|
|
@ -15,28 +17,31 @@ describe('normalizeContactName', () => {
|
|||
})
|
||||
|
||||
describe('assertContactNameAvailable', () => {
|
||||
const makePayload = (docs: { name: string; id?: number }[]) => ({
|
||||
type WhereClause = {
|
||||
and?: { name?: { like?: string }; id?: { not_equals?: number } }[]
|
||||
}
|
||||
const makePayload = (docs: { name: string; id?: number }[]): Pick<Payload, 'db' | 'find'> => ({
|
||||
db: {
|
||||
name: 'postgres',
|
||||
sessions: { tx: { db: { execute: vi.fn() } } },
|
||||
},
|
||||
find: vi.fn().mockImplementation(async ({ where }: { where: any }) => {
|
||||
const conditions: any[] = where?.and ?? []
|
||||
const idExclusion = conditions.find((c) => c.id?.not_equals)?.id.not_equals
|
||||
const like = conditions.find((c) => c.name?.like)?.name.like as string | undefined
|
||||
} as unknown as Payload['db'],
|
||||
find: vi.fn().mockImplementation(async ({ where }: { where?: WhereClause }) => {
|
||||
const conditions = where?.and ?? []
|
||||
const idExclusion = conditions.find((c) => c.id?.not_equals)?.id?.not_equals
|
||||
const like = conditions.find((c) => c.name?.like)?.name?.like
|
||||
const filtered = docs.filter((doc) => {
|
||||
if (idExclusion !== undefined && doc.id === idExclusion) return false
|
||||
if (like && !doc.name.toLowerCase().includes(like)) return false
|
||||
return true
|
||||
})
|
||||
return { docs: filtered, totalDocs: filtered.length }
|
||||
}),
|
||||
}) as unknown as Payload['find'],
|
||||
})
|
||||
|
||||
it('passes when no stored contact normalizes to the same name', async () => {
|
||||
const payload = makePayload([{ id: 9, name: 'Maria Souza' }])
|
||||
await expect(
|
||||
assertContactNameAvailable(payload as never, { transactionID: 'tx' }, ' maria silva '),
|
||||
assertContactNameAvailable(payload, { transactionID: 'tx' }, ' maria silva '),
|
||||
).resolves.toBeUndefined()
|
||||
// The DB prefilter runs the case-insensitive substring, the verdict is in memory.
|
||||
expect(payload.find).toHaveBeenCalledOnce()
|
||||
|
|
@ -44,20 +49,18 @@ describe('assertContactNameAvailable', () => {
|
|||
|
||||
it('throws the conflict message on a normalized-name match (case/whitespace insensitive)', async () => {
|
||||
const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
|
||||
await expect(assertContactNameAvailable(payload as never, {}, 'MARIA SILVA')).rejects.toThrow(
|
||||
await expect(assertContactNameAvailable(payload, {}, 'MARIA SILVA')).rejects.toThrow(
|
||||
CONTACT_NAME_CONFLICT_MESSAGE,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the contact itself when renaming (contactID excluded)', async () => {
|
||||
const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
|
||||
await expect(
|
||||
assertContactNameAvailable(payload as never, {}, 'Maria Silva', 9),
|
||||
).resolves.toBeUndefined()
|
||||
await expect(assertContactNameAvailable(payload, {}, 'Maria Silva', 9)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not collide on a partial-name substring match', async () => {
|
||||
const payload = makePayload([{ id: 9, name: 'Ana Maria' }])
|
||||
await expect(assertContactNameAvailable(payload as never, {}, 'Maria')).resolves.toBeUndefined()
|
||||
await expect(assertContactNameAvailable(payload, {}, 'Maria')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
105
tests/unit/contactSchemas.unit.spec.ts
Normal file
105
tests/unit/contactSchemas.unit.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
|
||||
import { contactCreateSchema, contactFieldUpdateSchema } from '@/lib/schemas/contact'
|
||||
|
||||
describe('contactCreateSchema (C139)', () => {
|
||||
it('accepts a full valid ficha', () => {
|
||||
const result = contactCreateSchema.safeParse({
|
||||
name: 'Maria Silva',
|
||||
email: 'maria@example.com',
|
||||
phones: ['71999990000'],
|
||||
gender: 'feminino',
|
||||
state: 'BA',
|
||||
city: 'Salvador',
|
||||
postalCode: '40000000',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the minimal ficha (name + state)', () => {
|
||||
const result = contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a missing state', () => {
|
||||
const result = contactCreateSchema.safeParse({ name: 'Ana Souza' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success)
|
||||
expect(result.error.issues.some((issue) => issue.path[0] === 'state')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an unknown gender', () => {
|
||||
expect(
|
||||
contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', gender: 'x' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a single-word name', () => {
|
||||
expect(contactCreateSchema.safeParse({ name: 'Ana', state: 'BA' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects duplicated phones within the same ficha (C112)', () => {
|
||||
const result = contactCreateSchema.safeParse({
|
||||
name: 'Ana Souza',
|
||||
state: 'BA',
|
||||
phones: ['71999990000', '71999990000'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.message === BRAZILIAN_PHONE_DUPLICATE_MESSAGE),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts an empty phone list', () => {
|
||||
expect(
|
||||
contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', phones: [] }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contactFieldUpdateSchema — C139 variants', () => {
|
||||
it('accepts the city variant', () => {
|
||||
expect(
|
||||
contactFieldUpdateSchema.safeParse({ id: 5, field: 'city', city: 'Camaçari' }).success,
|
||||
).toBe(true)
|
||||
expect(contactFieldUpdateSchema.safeParse({ id: 5, field: 'city', city: 'C' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('transforms an empty postalCode to undefined (clear semantics)', () => {
|
||||
const result = contactFieldUpdateSchema.safeParse({
|
||||
id: 5,
|
||||
field: 'postalCode',
|
||||
postalCode: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
const data = result.data as { field: string; postalCode?: string }
|
||||
expect(data.postalCode).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts an 8-digit postalCode and rejects a malformed one', () => {
|
||||
expect(
|
||||
contactFieldUpdateSchema.safeParse({ id: 5, field: 'postalCode', postalCode: '42800000' })
|
||||
.success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
contactFieldUpdateSchema.safeParse({ id: 5, field: 'postalCode', postalCode: 'abc' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the legacy variants working', () => {
|
||||
expect(
|
||||
contactFieldUpdateSchema.safeParse({ id: 5, field: 'name', name: 'Ana Souza' }).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
contactFieldUpdateSchema.safeParse({ id: 5, field: 'phones', phones: ['71999990000'] })
|
||||
.success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue