ad2app-lib 1.44.1 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+
4
+ import { ACCESS_DENIAL_CODES } from './I_AccessDenial';
5
+ import type { I_AgencyBilling, I_SponsoredBy } from './agency';
6
+ import { SchedulingSubscriptionInfoDTO } from './scheduling/I_SchedulingProUpgrade';
7
+
8
+ /**
9
+ * Spec 175 US4 (T022/T024). The billing contract between backend and frontend:
10
+ * the settings screen, the creator's own subscription screen, and the one new
11
+ * refusal a sponsored creator can meet.
12
+ */
13
+
14
+ test('a sponsored creator without a Stripe customer is refused the portal with its own code', () => {
15
+ assert.equal(ACCESS_DENIAL_CODES.SPONSORED_NO_PORTAL, 'sponsored-no-portal');
16
+ });
17
+
18
+ test('the subscription DTO carries sponsoredBy when the agency covers the plan', () => {
19
+ const sponsoredBy: I_SponsoredBy = { organizationName: 'Northlight', tier: 'pro', until: '2026-10-04T00:00:00.000Z' };
20
+ const dto = new SchedulingSubscriptionInfoDTO({
21
+ tier: 'pro',
22
+ status: 'active',
23
+ expiresAt: null,
24
+ maxAccounts: 10,
25
+ sponsoredBy,
26
+ });
27
+ assert.deepEqual(dto.sponsoredBy, sponsoredBy);
28
+ });
29
+
30
+ test('the subscription DTO leaves sponsoredBy ABSENT for everyone else, never null', () => {
31
+ const dto = new SchedulingSubscriptionInfoDTO({ tier: 'free', status: 'expired', expiresAt: null, maxAccounts: 0 });
32
+ assert.equal('sponsoredBy' in dto, false);
33
+ });
34
+
35
+ test('the billing overview names seats, price, invoices, who is covered and who left', () => {
36
+ const billing: I_AgencyBilling = {
37
+ subscriptionStatus: 'active',
38
+ seats: 3,
39
+ billedSeats: 3,
40
+ price: { amount: 2500, currency: 'USD', interval: 'month' },
41
+ currentPeriodEnd: '2026-10-04T00:00:00.000Z',
42
+ cancelAtPeriodEnd: false,
43
+ nextInvoice: { amount: 7500, currency: 'USD', date: '2026-10-04T00:00:00.000Z' },
44
+ discount: { name: 'First agency', percentOff: 100 },
45
+ paymentMethod: { brand: 'visa', last4: '4242', expMonth: 9, expYear: 2029 },
46
+ billingDetails: { name: 'Northlight Talent', email: 'kontakt@northlight.pl', country: 'PL' },
47
+ invoices: [
48
+ { id: 'in_1', number: 'AD2-0001', date: '2026-09-04T00:00:00.000Z', amount: 7500, currency: 'USD', status: 'paid' },
49
+ ],
50
+ covered: [
51
+ { grantId: 'g1', creator: { id: 'u1', handle: 'marta.moves' }, tier: 'pro', since: '2026-09-10T00:00:00.000Z' },
52
+ { grantId: 'g2', creator: { id: 'u2', handle: 'kubaplays' }, tier: null },
53
+ ],
54
+ pastCreators: [
55
+ {
56
+ grantId: 'g3',
57
+ creator: { id: null, handle: 'bartek.runs' },
58
+ endedAt: '2026-09-20T00:00:00.000Z',
59
+ endedBy: 'creator',
60
+ coveredUntil: '2026-10-04T00:00:00.000Z',
61
+ },
62
+ ],
63
+ };
64
+ assert.equal(billing.covered.filter((row) => row.tier !== null).length, 1);
65
+ assert.equal(billing.pastCreators[0].creator.id, null);
66
+ });
67
+
68
+ test('billedSeats is what Stripe bills: never below one, while seats is the creators with an active grant (review M1)', () => {
69
+ const billing: I_AgencyBilling = {
70
+ subscriptionStatus: 'none', seats: 0, billedSeats: 1, price: null, invoices: [], covered: [], pastCreators: [],
71
+ };
72
+ assert.equal(billing.billedSeats, 1);
73
+ });
74
+
75
+ test('the subscription DTO carries a RECENTLY ENDED coverage so the creator is told why the paywall is back (review M4, FR-014)', () => {
76
+ const dto = new SchedulingSubscriptionInfoDTO({
77
+ tier: 'free', status: 'expired', expiresAt: null, maxAccounts: 0,
78
+ sponsorshipEnded: { organizationName: 'Northlight', endedAt: '2026-10-04T00:00:00.000Z' },
79
+ });
80
+ assert.deepEqual(dto.sponsorshipEnded, { organizationName: 'Northlight', endedAt: '2026-10-04T00:00:00.000Z' });
81
+ const plain = new SchedulingSubscriptionInfoDTO({ tier: 'free', status: 'expired', expiresAt: null, maxAccounts: 0 });
82
+ assert.equal('sponsorshipEnded' in plain, false);
83
+ });
@@ -0,0 +1,85 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ AGENCY_CAMPAIGN_STEPS,
6
+ AGENCY_OFFER_STATUSES,
7
+ CAMPAIGN_PLACEMENTS,
8
+ CampaignOfferActDto,
9
+ CreateAgencyCampaignDto,
10
+ SETTLEMENT_FORMS,
11
+ isCampaignPlacement,
12
+ } from "./agency-campaigns";
13
+ import { I_OfferStatus } from "./I_Offer";
14
+
15
+ test("the placements list is exactly FR-025's, one list per platform", () => {
16
+ assert.deepEqual(CAMPAIGN_PLACEMENTS, {
17
+ instagram: ["reel", "post", "carousel", "stories", "collab_post"],
18
+ tiktok: ["video", "repost", "duet", "stories"],
19
+ youtube: ["video", "short", "integration", "community_post"],
20
+ facebook: ["post", "reel", "stories"],
21
+ linkedin: ["post", "article"],
22
+ });
23
+ });
24
+
25
+ test("isCampaignPlacement checks the PAIR, and fails closed", () => {
26
+ assert.equal(isCampaignPlacement("instagram", "reel"), true);
27
+ assert.equal(isCampaignPlacement("youtube", "short"), true);
28
+ // A real placement on the wrong platform is not a placement.
29
+ assert.equal(isCampaignPlacement("linkedin", "reel"), false);
30
+ assert.equal(isCampaignPlacement("twitter", "post"), false);
31
+ // Prototype keys are not platforms.
32
+ assert.equal(isCampaignPlacement("constructor", "post"), false);
33
+ assert.equal(isCampaignPlacement("__proto__", "post"), false);
34
+ });
35
+
36
+ test("withdrawn is ADDED to the offer enum and the agency loop never writes the legacy-only values", () => {
37
+ assert.equal(I_OfferStatus.WITHDRAWN, "withdrawn");
38
+ for (const legacyOnly of [I_OfferStatus.DRAFT, I_OfferStatus.DELETED, I_OfferStatus.ALTERNATIVE_SUGGESTED]) {
39
+ assert.equal((AGENCY_OFFER_STATUSES as readonly string[]).includes(legacyOnly), false);
40
+ }
41
+ });
42
+
43
+ test("the board has FR-024's columns in loop order", () => {
44
+ assert.deepEqual(
45
+ [...AGENCY_CAMPAIGN_STEPS],
46
+ ["invited", "joined", "offer_sent", "negotiating", "confirmed", "material_in", "approved", "published"],
47
+ );
48
+ });
49
+
50
+ test("the settlement forms are the creator's three (FR-027)", () => {
51
+ assert.deepEqual([...SETTLEMENT_FORMS], ["invoice", "contract", "barter"]);
52
+ });
53
+
54
+ test("DTOs construct with no arguments, as the validation pipe does", () => {
55
+ assert.doesNotThrow(() => new CreateAgencyCampaignDto());
56
+ assert.doesNotThrow(() => new CampaignOfferActDto());
57
+ assert.equal(new CreateAgencyCampaignDto({ name: "Spring", text: "Concept" }).text, "Concept");
58
+ });
59
+
60
+ test("a material is a raster image or a video: SVG and HTML are refused (review L5)", async () => {
61
+ const { validate } = await import("class-validator");
62
+ const { PrepareMaterialUploadDto } = await import("./agency-campaigns");
63
+ const check = async (contentType: string) =>
64
+ (await validate(new PrepareMaterialUploadDto({ fileName: "f", contentType, sizeBytes: 1 }))).length === 0;
65
+ assert.equal(await check("video/mp4"), true);
66
+ assert.equal(await check("image/jpeg"), true);
67
+ assert.equal(await check("image/svg+xml"), false);
68
+ assert.equal(await check("text/html"), false);
69
+ });
70
+
71
+ test("thread messages are bounded and never empty (US8)", async () => {
72
+ const { validate } = await import("class-validator");
73
+ const { PostThreadMessageDto, THREAD_MESSAGE_MAX, AGENCY_THREAD_CODES } = await import("./agency-threads");
74
+ assert.equal((await validate(new PostThreadMessageDto({ text: "hi" }))).length, 0);
75
+ assert.notEqual((await validate(new PostThreadMessageDto({ text: "" }))).length, 0);
76
+ assert.notEqual((await validate(new PostThreadMessageDto({ text: "x".repeat(THREAD_MESSAGE_MAX + 1) }))).length, 0);
77
+ assert.equal(AGENCY_THREAD_CODES.CLOSED, "agency-thread-closed");
78
+ });
79
+
80
+ test("the picker's photo list and the API's rule are one rule (epic verify E7)", async () => {
81
+ const { MATERIAL_CONTENT_TYPE, MATERIAL_IMAGE_TYPES } = await import("./agency-campaigns");
82
+ for (const type of MATERIAL_IMAGE_TYPES) assert.equal(MATERIAL_CONTENT_TYPE.test(type), true, type);
83
+ for (const type of ["image/svg+xml", "text/plain", "image/pngx", "application/octet-stream", ""]) assert.equal(MATERIAL_CONTENT_TYPE.test(type), false, type);
84
+ assert.equal(MATERIAL_CONTENT_TYPE.test("video/quicktime"), true);
85
+ });