Files
PleinR/tests/promo-validity.test.ts
Claude e980ab6e04 Promotions : publication programmée et affichage limité à la période
Deux comportements demandés :

1. Une promotion n'apparaît sur le site que pendant sa période de validité.
   Les lectures publiques passent par `VISIBLE_PROMO` (`src/lib/queries.ts`) :
   statut `live` **et** fenêtre `starts_on` / `ends_on`, journée calculée en
   Europe/Paris pour qu'un conteneur en UTC ne retire pas une offre deux
   heures trop tôt. Le statut n'est pas touché : hors période, le backoffice
   explique pourquoi l'offre n'est pas visible (`visibilityNote`) et elle
   revient d'elle-même si les dates changent.

2. La publication peut être programmée. `promotions.publish_at` : l'adhérent
   propose une date, le modérateur la garde, la déplace ou la vide dans le
   formulaire « Valider ». Avec une échéance future la promotion passe au
   statut `scheduled` — rien n'est visible, rien n'est diffusé — et
   `releaseDuePromotions()` fait la bascule à l'heure dite, site et réseaux
   ensemble. Un bouton « Publier maintenant » court-circuite l'attente.

Mécanique :
- `src/lib/promo-schedule.ts` (pur, testé) : conversion `datetime-local` ⇄
  heure de l'association, changements d'heure compris, et refus d'une saisie
  illisible plutôt qu'une publication immédiate involontaire.
- `src/lib/promo-publish.ts` reprend `publishPromoShares()` : deux appelants
  désormais, une action serveur et la boucle de libération, qui n'a pas de
  requête et ne peut donc pas appeler `revalidatePath()`. Idem pour
  `logActivity()` dans `src/lib/activity-log.ts`.
- La bascule `scheduled → live` est un `UPDATE … RETURNING` filtré sur le
  statut : atomique, donc pas de double publication même à plusieurs
  instances, en plus de la garde existante sur `social_posts`.
- Déclencheur : `src/instrumentation.ts`, boucle d'une minute avec rattrapage
  au démarrage (`PROMO_SCHEDULER=off` la désactive). Le travail vit dans
  `instrumentation-node.ts`, écarté du bundle edge par `next.config.mjs` :
  Next compile aussi l'instrumentation pour le middleware, où `pg` ne peut
  pas être embarqué.

Migration 0016 : `promo_status += 'scheduled'`, `promotions.publish_at`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 11:17:49 +00:00

101 lines
3.6 KiB
TypeScript

import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
formatValidity,
formatValidityShort,
isExpired,
isNotStarted,
isRangeInvalid,
visibilityNote,
} from "../src/lib/promo-validity";
describe("Période de validité d'une promotion", () => {
it("affiche les deux bornes", () => {
assert.equal(
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-03-15" }),
"Valable du 1er au 15 mars 2027"
);
});
it("ne répète pas l'année quand elle est identique", () => {
assert.equal(
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-04-10" }),
"Valable du 1er mars au 10 avril 2027"
);
});
it("écrit les deux années quand elles diffèrent", () => {
assert.equal(
formatValidity({ startsOn: "2027-12-20", endsOn: "2028-01-05" }),
"Valable du 20 décembre 2027 au 5 janvier 2028"
);
});
it("gère une seule borne", () => {
assert.equal(formatValidity({ endsOn: "2027-06-30" }), "Valable jusqu'au 30 juin 2027");
assert.equal(
formatValidity({ startsOn: "2027-06-01" }),
"Valable à partir du 1er juin 2027"
);
});
it("ne renvoie rien sans date ni texte : la carte n'affiche alors aucune mention", () => {
assert.equal(formatValidity({}), null);
assert.equal(formatValidity({ startsOn: null, endsOn: null, validUntil: " " }), null);
});
it("retombe sur l'ancien texte libre des promotions existantes", () => {
assert.equal(formatValidity({ validUntil: "Tout l'été" }), "Tout l'été");
// Les dates priment sur le texte libre.
assert.equal(
formatValidity({ endsOn: "2027-07-31", validUntil: "Tout l'été" }),
"Valable jusqu'au 31 juillet 2027"
);
});
it("ignore une date illisible", () => {
assert.equal(formatValidity({ endsOn: "pas-une-date" }), null);
assert.equal(formatValidity({ endsOn: "2027-13-45" }), null);
});
it("propose une variante sans le mot « Valable »", () => {
assert.equal(formatValidityShort({ endsOn: "2027-06-30" }), "jusqu'au 30 juin 2027");
});
it("considère le dernier jour comme encore valable", () => {
const end = { endsOn: "2027-06-30" };
assert.equal(isExpired(end, new Date("2027-06-30T23:00:00Z")), false);
assert.equal(isExpired(end, new Date("2027-07-01T12:00:00Z")), true);
assert.equal(isExpired({}, new Date("2099-01-01T00:00:00Z")), false);
});
it("détecte une fin antérieure au début", () => {
assert.equal(isRangeInvalid({ startsOn: "2027-06-10", endsOn: "2027-06-01" }), true);
assert.equal(isRangeInvalid({ startsOn: "2027-06-01", endsOn: "2027-06-01" }), false);
assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false);
});
});
describe("Visibilité publique d'une promotion", () => {
it("distingue avant, pendant et après la période", () => {
const promo = { startsOn: "2027-03-01", endsOn: "2027-03-15" };
assert.equal(
visibilityNote(promo, new Date("2027-02-20T12:00:00Z")),
"Pas encore affichée : elle apparaîtra sur le site le 1er mars 2027."
);
assert.equal(visibilityNote(promo, new Date("2027-03-01T00:00:00Z")), null);
assert.equal(visibilityNote(promo, new Date("2027-03-15T23:00:00Z")), null);
assert.equal(
visibilityNote(promo, new Date("2027-03-16T08:00:00Z")),
"Période terminée le 15 mars 2027 : elle n'apparaît plus sur le site."
);
});
it("laisse visible une offre sans date", () => {
assert.equal(visibilityNote({ startsOn: null, endsOn: null }), null);
assert.equal(visibilityNote({ validUntil: "Ce week-end" }), null);
assert.equal(isNotStarted({ startsOn: null }), false);
});
});