feat(12-03): add offer editor server actions
- saveOfferEditor(macroId, payload): persists macro scalars, per-tier upsert (tier_letter Zod-validated A/B/C), offer_tier_services delete-then-reinsert, and Tipo/Obiettivo tags delete-then-reinsert, all in one call - toggleOfferArchived(macroId, archived) for OFFER-18 - addOfferTag/removeOfferTag/renameOfferOption for Tipo/Obiettivo/ Categoria/Ticket dimensions, mirroring catalog option-pool pattern - createOfferMacro(formData) — Plan 04 "+ Nuova Offerta" target - scripts/verify-12-03-actions.ts documents the 9 spec'd test cases (typecheck-only, requireAdmin needs a request context)
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// Phase 12 Plan 03 — Task 2 verification script for actions.ts additions.
|
||||
//
|
||||
// This project has no test runner configured (no `scripts.test` in package.json),
|
||||
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
|
||||
// this script does NOT execute any DB calls or server actions (server actions
|
||||
// also require an Auth.js session, unavailable outside a request context). It
|
||||
// typechecks against the real exports/types from
|
||||
// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from
|
||||
// the plan's <behavior> block, for manual run against a dev DB later if desired.
|
||||
//
|
||||
// Run (manual, dev DB only, inside a request context with admin session):
|
||||
// npx tsx scripts/verify-12-03-actions.ts
|
||||
|
||||
import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions";
|
||||
import {
|
||||
saveOfferEditor,
|
||||
toggleOfferArchived,
|
||||
addOfferTag,
|
||||
removeOfferTag,
|
||||
renameOfferOption,
|
||||
createOfferMacro,
|
||||
} from "@/app/admin/offers/actions";
|
||||
|
||||
// ── Test 1: tier_letter Zod validation ───────────────────────────────────────
|
||||
// saveOfferEditor(macroId, payload) rejects (throws) when payload.tiers contains
|
||||
// a tier_letter not in ["A","B","C"] — Zod enum validation, defense-in-depth
|
||||
// alongside the DB CHECK constraint from Plan 01.
|
||||
async function test1_invalidTierLetter(macroId: string) {
|
||||
const badPayload = {
|
||||
internal_name: "Test",
|
||||
public_name: "Test",
|
||||
tiers: [
|
||||
{
|
||||
tier_letter: "D", // invalid — not in ["A","B","C"]
|
||||
internal_name: "Tier X",
|
||||
public_name: "Tier X",
|
||||
duration_months: 3,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
} as unknown as SaveOfferEditorPayload;
|
||||
// Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch
|
||||
// surfaces as parsed.error.issues[0].message before any DB write).
|
||||
return () => saveOfferEditor(macroId, badPayload);
|
||||
}
|
||||
|
||||
// ── Test 2: macro scalar field update ────────────────────────────────────────
|
||||
// saveOfferEditor updates offer_macros scalars (internal_name, description,
|
||||
// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one
|
||||
// db.update(offer_macros)...where(eq(id, macroId)).
|
||||
async function test2_macroScalars(macroId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
description: "Una descrizione",
|
||||
category: "Signature Offer",
|
||||
ticket: "Mid Ticket",
|
||||
cliente_ideale: "Coach",
|
||||
risultato: "Lead qualificati",
|
||||
tempo: "90 giorni",
|
||||
pain: "Mancanza di processo",
|
||||
metodo: "Sistema X",
|
||||
tiers: [],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: after call, offer_macros row for macroId has all 9 scalar
|
||||
// fields set to the payload values.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 3: tier upsert (update existing id, insert when no id) ──────────────
|
||||
// If tier.id is provided -> db.update(offer_micros).set({...}); if no id ->
|
||||
// db.insert(offer_micros).values({..., macro_id: macroId}).
|
||||
async function test3_tierUpsert(macroId: string, existingTierId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [
|
||||
{
|
||||
id: existingTierId, // -> update path
|
||||
tier_letter: "A",
|
||||
internal_name: "Tier A",
|
||||
public_name: "Tier A Pubblico",
|
||||
duration_months: 3,
|
||||
public_price: 1500,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
{
|
||||
// no id -> insert path (supports macros with < 3 tiers)
|
||||
tier_letter: "B",
|
||||
internal_name: "Tier B",
|
||||
public_name: "Tier B Pubblico",
|
||||
duration_months: 6,
|
||||
public_price: 2500,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: existingTierId row updated in place; a new offer_micros row
|
||||
// created for tier B with macro_id = macroId.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 4: offer_tier_services delete-then-reinsert ─────────────────────────
|
||||
// A tier with assignedServiceIds: ["svc1","svc2"] -> after call,
|
||||
// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2.
|
||||
async function test4_tierServicesReplace(macroId: string, tierId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [
|
||||
{
|
||||
id: tierId,
|
||||
tier_letter: "A",
|
||||
internal_name: "Tier A",
|
||||
public_name: "Tier A Pubblico",
|
||||
duration_months: 3,
|
||||
assignedServiceIds: ["svc1", "svc2"],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: offer_tier_services has exactly 2 rows where tier_id === tierId,
|
||||
// service_id IN ("svc1", "svc2").
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ─────────────────────────
|
||||
// saveOfferEditor replaces tags where entity_type IN
|
||||
// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId.
|
||||
async function test5_tagsReplace(macroId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [],
|
||||
tipoTags: ["Audit", "Coaching"],
|
||||
obiettivoTags: ["Lead Generation"],
|
||||
};
|
||||
// Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit,
|
||||
// Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead
|
||||
// Generation), all entity_id = macroId; any prior rows for this macroId +
|
||||
// these two entity_types are removed first.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 6: toggleOfferArchived ──────────────────────────────────────────────
|
||||
// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived.
|
||||
async function test6_toggleArchived(macroId: string) {
|
||||
// Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true);
|
||||
// === false after toggleOfferArchived(macroId, false).
|
||||
return [
|
||||
() => toggleOfferArchived(macroId, true),
|
||||
() => toggleOfferArchived(macroId, false),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Test 7: addOfferTag / removeOfferTag ─────────────────────────────────────
|
||||
// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId,
|
||||
// value) work for dimension in ("tipo","obiettivo"). Reject any other
|
||||
// dimension value.
|
||||
async function test7_tagCrud(macroId: string) {
|
||||
// Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row
|
||||
// (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit");
|
||||
// removeOfferTag("tipo", macroId, "Audit") deletes it.
|
||||
// addOfferTag("invalid" as any, macroId, "x") throws.
|
||||
return {
|
||||
addTipo: () => addOfferTag("tipo", macroId, "Audit"),
|
||||
removeTipo: () => removeOfferTag("tipo", macroId, "Audit"),
|
||||
addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"),
|
||||
removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"),
|
||||
// @ts-expect-error -- intentional invalid dimension to verify the throw path
|
||||
invalid: () => addOfferTag("invalid", macroId, "x"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 8: renameOfferOption ────────────────────────────────────────────────
|
||||
// renameOfferOption(field, oldValue, newValue) for field in ("categoria",
|
||||
// "ticket") updates offer_macros.category/ticket for all matching rows; for
|
||||
// field in ("tipo","obiettivo") updates tags.name.
|
||||
async function test8_renameOption() {
|
||||
// Expected:
|
||||
// renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates
|
||||
// all offer_macros rows where category = "Entry Offer" -> "Offerta Base"
|
||||
// renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates
|
||||
// all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso"
|
||||
// renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows
|
||||
// where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi"
|
||||
// renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead")
|
||||
// updates tags rows where entity_type="offer_macros.obiettivo"
|
||||
return {
|
||||
renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"),
|
||||
renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"),
|
||||
renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"),
|
||||
renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 9: createOfferMacro ─────────────────────────────────────────────────
|
||||
// createOfferMacro(formData) creates a new offer_macros row from internal_name
|
||||
// (required) + optional public_name/description/category. If public_name is
|
||||
// omitted, defaults to internal_name.
|
||||
async function test9_createOfferMacro() {
|
||||
const fd1 = new FormData();
|
||||
fd1.append("internal_name", "Nuova Offerta Interna");
|
||||
fd1.append("description", "Descrizione breve");
|
||||
fd1.append("category", "Entry Offer");
|
||||
// Expected: new offer_macros row with internal_name="Nuova Offerta Interna",
|
||||
// public_name="Nuova Offerta Interna" (defaulted), description set,
|
||||
// category="Entry Offer".
|
||||
|
||||
const fd2 = new FormData();
|
||||
fd2.append("internal_name", "Altra Offerta");
|
||||
fd2.append("public_name", "Nome Pubblico Esplicito");
|
||||
// Expected: new offer_macros row with public_name="Nome Pubblico Esplicito"
|
||||
// (not defaulted, since explicitly provided).
|
||||
|
||||
return {
|
||||
withDefaultPublicName: () => createOfferMacro(fd1),
|
||||
withExplicitPublicName: () => createOfferMacro(fd2),
|
||||
};
|
||||
}
|
||||
|
||||
// Not executed automatically (production DB + no request context for
|
||||
// requireAdmin) — typecheck-only verification.
|
||||
|
||||
export {
|
||||
test1_invalidTierLetter,
|
||||
test2_macroScalars,
|
||||
test3_tierUpsert,
|
||||
test4_tierServicesReplace,
|
||||
test5_tagsReplace,
|
||||
test6_toggleArchived,
|
||||
test7_tagCrud,
|
||||
test8_renameOption,
|
||||
test9_createOfferMacro,
|
||||
};
|
||||
Reference in New Issue
Block a user